feat(core): finalize session event lifecycle (#35272)
This commit is contained in:
parent
b046bbe4e3
commit
bf01264661
82 changed files with 5200 additions and 3711 deletions
|
|
@ -3,13 +3,19 @@ export * as SessionRunner from "./index"
|
|||
import type { LLMError } from "@opencode-ai/llm"
|
||||
import { Context, Effect } from "effect"
|
||||
import { SessionSchema } from "../schema"
|
||||
import type { MessageDecodeError } from "../error"
|
||||
import type { MessageDecodeError, StepFailedError, UserInterruptedError } from "../error"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import type { Instructions } from "../../instructions/index"
|
||||
import type { ToolOutputStore } from "../../tool-output-store"
|
||||
|
||||
export type RunError =
|
||||
LLMError | SessionRunnerModel.Error | MessageDecodeError | Instructions.InitializationBlocked | ToolOutputStore.Error
|
||||
| LLMError
|
||||
| SessionRunnerModel.Error
|
||||
| MessageDecodeError
|
||||
| StepFailedError
|
||||
| UserInterruptedError
|
||||
| Instructions.InitializationBlocked
|
||||
| ToolOutputStore.Error
|
||||
|
||||
/** Runs one local continuation from already-recorded Session history. */
|
||||
export interface Interface {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ import {
|
|||
isContextOverflowFailure,
|
||||
type ProviderErrorEvent,
|
||||
} from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
import { Database } from "../../database/database"
|
||||
|
|
@ -32,6 +33,7 @@ import { SessionCompaction } from "../compaction"
|
|||
import { SessionEvent } from "../event"
|
||||
import { SessionHistory } from "../history"
|
||||
import { SessionInput } from "../input"
|
||||
import { SessionMessage } from "../message"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
import { SessionTitle } from "../title"
|
||||
|
|
@ -44,6 +46,9 @@ import { SessionRunnerSystemPrompt } from "./system-prompt"
|
|||
import { Snapshot } from "../../snapshot"
|
||||
import { makeLocationNode } from "../../effect/app-node"
|
||||
import { llmClient } from "../../effect/app-node-platform"
|
||||
import { StepFailedError, UserInterruptedError } from "../error"
|
||||
import { toSessionError } from "../to-session-error"
|
||||
import { SessionRunnerRetry } from "./retry"
|
||||
|
||||
/**
|
||||
* Runs one durable coding-agent Session until it settles.
|
||||
|
|
@ -54,10 +59,10 @@ import { llmClient } from "../../effect/app-node-platform"
|
|||
* - Session ownership and controls
|
||||
* - [x] Coordinate one local active drain per Session; explicit resumes join and prompt wakeups coalesce.
|
||||
* - [ ] Replace local ownership with durable multi-node ownership when clustered.
|
||||
* - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably.
|
||||
* - [x] Publish durable historical execution lifecycle and bounded retry observations.
|
||||
* - [ ] Honor interruption and reject stale work after runtime attachment replacement.
|
||||
* - [x] Honor optional agent step limits.
|
||||
* - [ ] Bound provider retries and repeated identical tool calls.
|
||||
* - [ ] Bound repeated identical tool calls (provider retries are bounded).
|
||||
*
|
||||
* - Runtime context assembly
|
||||
* - Track V1 runtime-context parity canonically in `specs/v2/session.md`.
|
||||
|
|
@ -66,7 +71,7 @@ import { llmClient } from "../../effect/app-node-platform"
|
|||
* - [x] Translate every projected V2 Session message variant into canonical
|
||||
* `@opencode-ai/llm` messages.
|
||||
* - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output tool definitions.
|
||||
* - [x] Stream exactly one `llm.stream(request)` physical attempt.
|
||||
* - [x] Stream exactly one `llm.stream(request)` call per attempt.
|
||||
* - [x] Persist assistant text and usage events incrementally as they arrive.
|
||||
* - [ ] Persist snapshots, patches, and retry notices incrementally as they arrive.
|
||||
* - [x] Persist reasoning, provider errors, and tool-call events incrementally as they arrive.
|
||||
|
|
@ -87,7 +92,7 @@ import { llmClient } from "../../effect/app-node-platform"
|
|||
* - [ ] Coalesce streamed deltas and add covering projected-history indexes.
|
||||
* - [ ] Update title, summaries, compaction state, and cleanup in bounded background work.
|
||||
*
|
||||
* Use `llm.stream(request)` for each physical attempt. Keep tool execution and continuation here.
|
||||
* Use `llm.stream(request)` for each attempt. Keep tool execution and continuation here.
|
||||
* Durable continuation recovery remains a separate future slice with an explicit retry policy.
|
||||
*
|
||||
* The current slice loads V2 history, translates it, resolves a model through a core service, and persists one
|
||||
|
|
@ -137,19 +142,13 @@ const layer = Layer.effect(
|
|||
sessionID,
|
||||
assistantMessageID: message.id,
|
||||
callID: tool.id,
|
||||
error: { type: "unknown", message: "Tool execution interrupted" },
|
||||
provider: {
|
||||
executed: tool.provider?.executed === true,
|
||||
...(tool.provider?.metadata === undefined ? {} : { metadata: tool.provider.metadata }),
|
||||
},
|
||||
error: { type: "tool.stale", message: `Tool execution interrupted: ${tool.name}` },
|
||||
executed: tool.executed === true,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
|
||||
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
|
||||
|
||||
// Declining an interactive prompt halts the drain instead of becoming model-facing tool output.
|
||||
const isUserDeclined = (cause: Cause.Cause<unknown>) =>
|
||||
cause.reasons.some(
|
||||
|
|
@ -176,6 +175,7 @@ const layer = Layer.effect(
|
|||
promotion: SessionInput.Delivery | undefined,
|
||||
step: number,
|
||||
recoverOverflow?: typeof compaction.compactAfterOverflow,
|
||||
assistantMessageID?: SessionMessage.ID,
|
||||
) {
|
||||
const session = yield* getSession(sessionID)
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
|
|
@ -189,7 +189,8 @@ const layer = Layer.effect(
|
|||
loadInstructions(agent, session.id),
|
||||
session.id,
|
||||
)
|
||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error | UserInterruptedError>()
|
||||
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error | UserInterruptedError>> = []
|
||||
let needsContinuation = false
|
||||
let currentStep = step
|
||||
if (promotion) {
|
||||
|
|
@ -236,21 +237,23 @@ const layer = Layer.effect(
|
|||
// The selected catalog identity, not model.id: route-level ids are provider API
|
||||
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
|
||||
model: resolved.ref,
|
||||
provider: model.provider,
|
||||
snapshot: startSnapshot,
|
||||
assistantMessageID,
|
||||
})
|
||||
const publication = Semaphore.makeUnsafe(1)
|
||||
// Durable publishes are serialized so tool fibers and step settlement never interleave
|
||||
// mid-event.
|
||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
|
||||
serialized(publisher.publish(event, outputPaths))
|
||||
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = [], error?: SessionError.Error) =>
|
||||
serialized(publisher.publish(event, outputPaths, error))
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm.stream(request).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
if (LLMEvent.is.providerError(event)) {
|
||||
if (isContextOverflowFailure(event) && !publisher.hasAssistantStarted()) {
|
||||
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
|
||||
overflowFailure = event
|
||||
return
|
||||
}
|
||||
|
|
@ -258,33 +261,49 @@ const layer = Layer.effect(
|
|||
yield* publish(event)
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
if (!toolMaterialization) {
|
||||
yield* serialized(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps"))
|
||||
yield* serialized(
|
||||
publisher.failUnsettledTools({
|
||||
type: "tool.execution",
|
||||
message: "Tools are disabled after the maximum agent steps",
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
needsContinuation = true
|
||||
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
restore(
|
||||
toolMaterialization.settle({
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
assistantMessageID,
|
||||
call: event,
|
||||
}),
|
||||
).pipe(
|
||||
Effect.flatMap((settlement) =>
|
||||
publish(
|
||||
LLMEvent.toolResult({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
result: settlement.result,
|
||||
output: settlement.output,
|
||||
}),
|
||||
settlement.outputPaths ?? [],
|
||||
ownedToolFibers.push(
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
restore(
|
||||
toolMaterialization.settle({
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
assistantMessageID,
|
||||
call: event,
|
||||
}),
|
||||
).pipe(
|
||||
Effect.flatMap((settlement) =>
|
||||
publish(
|
||||
LLMEvent.toolResult({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
result: settlement.result,
|
||||
output: settlement.output,
|
||||
}),
|
||||
settlement.outputPaths ?? [],
|
||||
settlement.error,
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
settlement.error?.type === "permission.rejected"
|
||||
? serialized(publisher.failAssistant(settlement.error)).pipe(
|
||||
Effect.andThen(Effect.fail(new UserInterruptedError())),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
).pipe(FiberSet.run(toolFibers))
|
||||
).pipe(FiberSet.run(toolFibers)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(serialized(publisher.flush())),
|
||||
|
|
@ -327,64 +346,118 @@ const layer = Layer.effect(
|
|||
// restart the step instead of surfacing the provider error.
|
||||
if (
|
||||
recoverOverflow &&
|
||||
!publisher.hasAssistantStarted() &&
|
||||
!publisher.hasRetryEvidence() &&
|
||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request })))
|
||||
)
|
||||
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
||||
|
||||
// An unrecovered held-back overflow becomes the step's durable provider error. A
|
||||
// thrown LLM failure fails hosted tool calls and the assistant unless a provider
|
||||
// error was already recorded from the stream.
|
||||
// thrown LLM failure records the assistant failure unless a provider error was
|
||||
// already recorded from the stream. Terminal publication waits for owned tools.
|
||||
if (overflowFailure) yield* publish(overflowFailure)
|
||||
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
|
||||
if (llmFailure && !publisher.hasProviderError()) {
|
||||
yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
yield* serialized(publisher.failAssistant(llmFailure.reason.message))
|
||||
const error = toSessionError(llmFailure)
|
||||
if (
|
||||
SessionRunnerRetry.isRetryable(llmFailure) &&
|
||||
!publisher.hasRetryEvidence() &&
|
||||
(agent.info?.steps === undefined || currentStep < agent.info.steps)
|
||||
) {
|
||||
return yield* new SessionRunnerRetry.RetryableFailure({
|
||||
cause: llmFailure,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
error,
|
||||
step: currentStep,
|
||||
})
|
||||
}
|
||||
yield* serialized(publisher.failAssistant(error))
|
||||
}
|
||||
// Provider error events only arrive from the stream, so the flag is final here.
|
||||
const providerFailed = publisher.hasProviderError()
|
||||
|
||||
// Settle tool fibers: an interrupted stream abandons unstarted tool work first.
|
||||
// Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain
|
||||
// the individual fibers and await all exits before publishing the terminal step event.
|
||||
if (streamInterrupted) yield* FiberSet.clear(toolFibers)
|
||||
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
|
||||
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
|
||||
const userDeclined = settled._tag === "Failure" && isUserDeclined(settled.cause)
|
||||
const settled = yield* restore(
|
||||
Effect.forEach(ownedToolFibers, Fiber.await, { concurrency: "unbounded" }),
|
||||
).pipe(Effect.exit)
|
||||
const settledCauses =
|
||||
settled._tag === "Failure"
|
||||
? [settled.cause]
|
||||
: settled.value.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
|
||||
const toolsInterrupted = settledCauses.some(Cause.hasInterrupts)
|
||||
const userDeclined = settledCauses.some(isUserDeclined)
|
||||
const permissionRejected = settledCauses.some(
|
||||
(cause) => Option.getOrUndefined(Cause.findErrorOption(cause)) instanceof UserInterruptedError,
|
||||
)
|
||||
|
||||
if (userDeclined || streamInterrupted || toolsInterrupted) {
|
||||
if (userDeclined || permissionRejected || streamInterrupted || toolsInterrupted) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
yield* serialized(publisher.failAssistant("Step interrupted"))
|
||||
if (userDeclined) return yield* Effect.interrupt
|
||||
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||
yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }))
|
||||
}
|
||||
// A settled tool fiber failure is one of two things. A defect from a tool
|
||||
// implementation becomes a failed tool call the model can read, and the step still
|
||||
// settles so the model may recover. A typed infrastructure failure (tool output
|
||||
// could not be persisted) also fails the assistant and then fails the drain.
|
||||
const settledFailure = settled._tag === "Failure" && !toolsInterrupted ? settled.cause : undefined
|
||||
const settledFailure = settledCauses.find(
|
||||
(cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause) && !permissionRejected,
|
||||
)
|
||||
const infraError =
|
||||
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
|
||||
if (settledFailure !== undefined) {
|
||||
const failure = infraError ?? Cause.squash(settledFailure)
|
||||
const message = failure instanceof Error ? failure.message : String(failure)
|
||||
yield* serialized(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
|
||||
if (infraError !== undefined)
|
||||
yield* serialized(publisher.failAssistant(`Tool execution failed: ${message}`))
|
||||
const error = toSessionError(failure)
|
||||
yield* serialized(publisher.failUnsettledTools(error))
|
||||
if (infraError !== undefined) yield* serialized(publisher.failAssistant(error))
|
||||
}
|
||||
|
||||
// Fail unresolved calls before the terminal step event. Local calls have joined, so
|
||||
// these sweeps only close calls that could not produce a truthful settlement.
|
||||
if (providerFailed)
|
||||
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||
if (llmFailure && !providerFailed)
|
||||
yield* serialized(
|
||||
publisher.failUnsettledTools(
|
||||
{
|
||||
type: "tool.result-missing",
|
||||
message: "Provider did not return a tool result",
|
||||
},
|
||||
true,
|
||||
),
|
||||
)
|
||||
const hostedResultMissing =
|
||||
stream._tag === "Success" && !providerFailed
|
||||
? yield* serialized(
|
||||
publisher.failUnsettledTools(
|
||||
{ type: "tool.result-missing", message: "Provider did not return a tool result" },
|
||||
true,
|
||||
),
|
||||
)
|
||||
: false
|
||||
if (hostedResultMissing && !publisher.stepSettlement())
|
||||
yield* serialized(
|
||||
publisher.failAssistant({
|
||||
type: "tool.result-missing",
|
||||
message: "Provider did not return a tool result",
|
||||
}),
|
||||
)
|
||||
|
||||
const stepFailure = publisher.stepFailure()
|
||||
const stepSettlement = publisher.stepSettlement()
|
||||
const stepEndedCleanly =
|
||||
!streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed
|
||||
!streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed && !stepFailure
|
||||
if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement)
|
||||
// A provider error orphans recorded local calls; a clean stream can still leave
|
||||
// hosted calls without results.
|
||||
if (providerFailed) yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
if (stream._tag === "Success" && !providerFailed)
|
||||
yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
if (stepFailure) yield* serialized(publisher.publishStepFailure())
|
||||
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||
if (settled._tag === "Failure" && (toolsInterrupted || infraError !== undefined))
|
||||
return yield* Effect.failCause(settled.cause)
|
||||
if (userDeclined) return yield* Effect.interrupt
|
||||
if (permissionRejected) return yield* new UserInterruptedError()
|
||||
if ((toolsInterrupted || infraError !== undefined) && settledFailure)
|
||||
return yield* Effect.failCause(settledFailure)
|
||||
if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
|
||||
if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
|
||||
return {
|
||||
_tag: "Completed",
|
||||
needsContinuation: !providerFailed && needsContinuation,
|
||||
|
|
@ -405,8 +478,31 @@ const layer = Layer.effect(
|
|||
let recoverOverflow: typeof compaction.compactAfterOverflow | undefined = compaction.compactAfterOverflow
|
||||
let currentPromotion = promotion
|
||||
let currentStep = step
|
||||
let assistantMessageID: SessionMessage.ID | undefined
|
||||
while (true) {
|
||||
const attempt = yield* attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow)
|
||||
const attempt = yield* Effect.suspend(() =>
|
||||
attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow, assistantMessageID),
|
||||
).pipe(
|
||||
Effect.tapError((error) =>
|
||||
error instanceof SessionRunnerRetry.RetryableFailure
|
||||
? Effect.sync(() => {
|
||||
currentStep = error.step + 1
|
||||
assistantMessageID = error.assistantMessageID
|
||||
currentPromotion = undefined
|
||||
})
|
||||
: Effect.void,
|
||||
),
|
||||
Effect.retryOrElse(SessionRunnerRetry.schedule(events, sessionID), (error) => {
|
||||
if (!(error instanceof SessionRunnerRetry.RetryableFailure)) return Effect.fail(error)
|
||||
return events
|
||||
.publish(SessionEvent.Step.Failed, {
|
||||
sessionID,
|
||||
assistantMessageID: error.assistantMessageID,
|
||||
error: error.error,
|
||||
})
|
||||
.pipe(Effect.andThen(Effect.fail(error.cause)))
|
||||
}),
|
||||
)
|
||||
if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step }
|
||||
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
|
||||
yield* Effect.yieldNow
|
||||
|
|
@ -415,8 +511,7 @@ const layer = Layer.effect(
|
|||
}
|
||||
})
|
||||
|
||||
// ExecutionSettled is published per execution (busy period) by SessionExecution, not per
|
||||
// drain here.
|
||||
// Execution lifecycle is published per busy period by SessionExecution, not per drain here.
|
||||
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { EventV2 } from "../../event"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionMessage } from "../message"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
|
||||
type Input = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: string
|
||||
readonly model: ModelV2.Ref
|
||||
readonly provider: string
|
||||
readonly snapshot?: string
|
||||
readonly assistantMessageID?: SessionMessage.ID
|
||||
}
|
||||
|
||||
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
|
||||
|
|
@ -41,10 +44,10 @@ const message = (value: unknown) => {
|
|||
|
||||
type SettledOutput =
|
||||
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
|
||||
| { readonly error: { readonly type: "unknown"; readonly message: string } }
|
||||
| { readonly error: SessionError.Error }
|
||||
|
||||
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
|
||||
if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } }
|
||||
if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } }
|
||||
const settled = value ?? ToolOutput.fromResultValue(result)
|
||||
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
|
||||
return { structured: record(settled.structured), content: settled.content }
|
||||
|
|
@ -61,20 +64,25 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
called: boolean
|
||||
settled: boolean
|
||||
providerExecuted: boolean
|
||||
providerMetadata?: ProviderMetadata
|
||||
}
|
||||
>()
|
||||
const timestamp = DateTime.now
|
||||
let assistantMessageID: SessionMessage.ID | undefined
|
||||
let assistantActive = false
|
||||
let assistantFailed = false
|
||||
let assistantMessageID = input.assistantMessageID
|
||||
let stepStarted = false
|
||||
let stepFailed = false
|
||||
let providerFailed = false
|
||||
let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined
|
||||
let retryEvidence = false
|
||||
let stepFailure: SessionError.Error | undefined
|
||||
let stepSettlement:
|
||||
| {
|
||||
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]
|
||||
readonly tokens: ReturnType<typeof tokens>
|
||||
}
|
||||
| undefined
|
||||
|
||||
const startAssistant = Effect.fnUntraced(function* () {
|
||||
if (assistantMessageID !== undefined) return assistantMessageID
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
assistantActive = true
|
||||
if (stepStarted && assistantMessageID !== undefined) return assistantMessageID
|
||||
assistantMessageID ??= SessionMessage.ID.create()
|
||||
stepStarted = true
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
...input,
|
||||
assistantMessageID,
|
||||
|
|
@ -86,29 +94,34 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
assistantMessageID === undefined
|
||||
? Effect.die(new Error("Tool event before assistant step start"))
|
||||
: Effect.succeed(assistantMessageID)
|
||||
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.provider]
|
||||
|
||||
const fragments = (
|
||||
name: string,
|
||||
ended: (id: string, value: string, providerMetadata?: ProviderMetadata) => Effect.Effect<void>,
|
||||
ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>,
|
||||
single = false,
|
||||
) => {
|
||||
const chunks = new Map<string, string[]>()
|
||||
const chunks = new Map<string, { readonly ordinal: number; readonly values: string[] }>()
|
||||
let nextOrdinal = 0
|
||||
const start = (id: string) =>
|
||||
Effect.suspend(() => {
|
||||
if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`))
|
||||
chunks.set(id, [])
|
||||
return Effect.void
|
||||
if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`))
|
||||
const ordinal = nextOrdinal++
|
||||
chunks.set(id, { ordinal, values: [] })
|
||||
return Effect.succeed(ordinal)
|
||||
})
|
||||
const append = (id: string, value: string) =>
|
||||
Effect.suspend(() => {
|
||||
const current = chunks.get(id)
|
||||
if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`))
|
||||
current.push(value)
|
||||
return Effect.void
|
||||
current.values.push(value)
|
||||
return Effect.succeed(current.ordinal)
|
||||
})
|
||||
const end = Effect.fnUntraced(function* (id: string, providerMetadata?: ProviderMetadata) {
|
||||
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>) {
|
||||
const current = chunks.get(id)
|
||||
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
|
||||
yield* ended(id, current.join(""), providerMetadata)
|
||||
yield* ended(id, current.values.join(""), current.ordinal, state)
|
||||
chunks.delete(id)
|
||||
})
|
||||
const flush = Effect.fnUntraced(function* () {
|
||||
|
|
@ -117,26 +130,32 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
return { start, append, end, flush }
|
||||
}
|
||||
|
||||
const text = fragments("text", (textID, value) =>
|
||||
Effect.gen(function* () {
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
textID,
|
||||
text: value,
|
||||
})
|
||||
}),
|
||||
const text = fragments(
|
||||
"text",
|
||||
(_textID, value, ordinal) =>
|
||||
Effect.gen(function* () {
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
ordinal,
|
||||
text: value,
|
||||
})
|
||||
}),
|
||||
true,
|
||||
)
|
||||
const reasoning = fragments("reasoning", (reasoningID, value, providerMetadata) =>
|
||||
Effect.gen(function* () {
|
||||
yield* events.publish(SessionEvent.Reasoning.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
reasoningID,
|
||||
text: value,
|
||||
providerMetadata,
|
||||
})
|
||||
}),
|
||||
const reasoning = fragments(
|
||||
"reasoning",
|
||||
(_reasoningID, value, ordinal, state) =>
|
||||
Effect.gen(function* () {
|
||||
yield* events.publish(SessionEvent.Reasoning.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
ordinal,
|
||||
text: value,
|
||||
state,
|
||||
})
|
||||
}),
|
||||
true,
|
||||
)
|
||||
const toolInput = fragments("tool input", (callID, value) =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -191,37 +210,41 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
yield* flushFragments()
|
||||
})
|
||||
|
||||
const failAssistant = Effect.fnUntraced(function* (message: string) {
|
||||
if (assistantFailed) return
|
||||
const failAssistant = Effect.fnUntraced(function* (error: SessionError.Error, replace = false) {
|
||||
yield* flush()
|
||||
yield* startAssistant()
|
||||
if (replace || stepFailure === undefined) stepFailure = error
|
||||
})
|
||||
|
||||
const publishStepFailure = Effect.fnUntraced(function* () {
|
||||
if (stepFailed || stepFailure === undefined) return
|
||||
const assistantMessageID = yield* startAssistant()
|
||||
assistantActive = false
|
||||
assistantFailed = true
|
||||
stepFailed = true
|
||||
yield* events.publish(SessionEvent.Step.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
error: { type: "unknown", message },
|
||||
error: stepFailure,
|
||||
})
|
||||
})
|
||||
|
||||
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
|
||||
message: string,
|
||||
error: SessionError.Error,
|
||||
hostedOnly = false,
|
||||
) {
|
||||
let failed = false
|
||||
for (const [callID, tool] of tools) {
|
||||
if (tool.settled || (hostedOnly && !tool.providerExecuted)) continue
|
||||
tool.settled = true
|
||||
failed = true
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID,
|
||||
error: { type: "unknown", message },
|
||||
provider: {
|
||||
executed: tool.providerExecuted,
|
||||
...(tool.providerMetadata === undefined ? {} : { metadata: tool.providerMetadata }),
|
||||
},
|
||||
error,
|
||||
executed: tool.providerExecuted,
|
||||
})
|
||||
}
|
||||
return failed
|
||||
})
|
||||
|
||||
const assistantMessageIDForTool = (callID: string) => {
|
||||
|
|
@ -232,24 +255,27 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
|
||||
event: LLMEvent,
|
||||
outputPaths: ReadonlyArray<string> = [],
|
||||
error?: SessionError.Error,
|
||||
) {
|
||||
switch (event.type) {
|
||||
case "step-start":
|
||||
yield* startAssistant()
|
||||
return
|
||||
case "text-start":
|
||||
yield* text.start(event.id)
|
||||
retryEvidence = true
|
||||
const startedTextOrdinal = yield* text.start(event.id)
|
||||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
textID: event.id,
|
||||
ordinal: startedTextOrdinal,
|
||||
})
|
||||
return
|
||||
case "text-delta":
|
||||
yield* text.append(event.id, event.text)
|
||||
const deltaTextOrdinal = yield* text.append(event.id, event.text)
|
||||
yield* events.publish(SessionEvent.Text.Delta, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
textID: event.id,
|
||||
ordinal: deltaTextOrdinal,
|
||||
delta: event.text,
|
||||
})
|
||||
return
|
||||
|
|
@ -257,27 +283,29 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
yield* text.end(event.id)
|
||||
return
|
||||
case "reasoning-start":
|
||||
yield* reasoning.start(event.id)
|
||||
retryEvidence = true
|
||||
const startedReasoningOrdinal = yield* reasoning.start(event.id)
|
||||
yield* events.publish(SessionEvent.Reasoning.Started, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
reasoningID: event.id,
|
||||
providerMetadata: event.providerMetadata,
|
||||
ordinal: startedReasoningOrdinal,
|
||||
state: providerState(event.providerMetadata),
|
||||
})
|
||||
return
|
||||
case "reasoning-delta":
|
||||
yield* reasoning.append(event.id, event.text)
|
||||
const deltaReasoningOrdinal = yield* reasoning.append(event.id, event.text)
|
||||
yield* events.publish(SessionEvent.Reasoning.Delta, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
reasoningID: event.id,
|
||||
ordinal: deltaReasoningOrdinal,
|
||||
delta: event.text,
|
||||
})
|
||||
return
|
||||
case "reasoning-end":
|
||||
yield* reasoning.end(event.id, event.providerMetadata)
|
||||
yield* reasoning.end(event.id, providerState(event.providerMetadata))
|
||||
return
|
||||
case "tool-input-start":
|
||||
retryEvidence = true
|
||||
yield* startToolInput(event)
|
||||
return
|
||||
case "tool-input-delta": {
|
||||
|
|
@ -299,6 +327,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
yield* endToolInput(event)
|
||||
return
|
||||
case "tool-call": {
|
||||
retryEvidence = true
|
||||
if (!tools.has(event.id)) yield* startToolInput(event)
|
||||
const tool = tools.get(event.id)!
|
||||
if (!tool.inputEnded) yield* endToolInput(event)
|
||||
|
|
@ -307,21 +336,19 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`))
|
||||
tool.called = true
|
||||
tool.providerExecuted = event.providerExecuted === true
|
||||
tool.providerMetadata = event.providerMetadata
|
||||
const state = providerState(event.providerMetadata)
|
||||
yield* events.publish(SessionEvent.Tool.Called, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
tool: event.name,
|
||||
input: record(event.input),
|
||||
provider: {
|
||||
executed: tool.providerExecuted,
|
||||
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
|
||||
},
|
||||
executed: tool.providerExecuted,
|
||||
state,
|
||||
})
|
||||
return
|
||||
}
|
||||
case "tool-result": {
|
||||
retryEvidence = true
|
||||
const tool = tools.get(event.id)
|
||||
if (!tool?.called) return yield* Effect.die(new Error(`Tool result before call: ${event.id}`))
|
||||
if (tool.name !== event.name)
|
||||
|
|
@ -331,11 +358,9 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`))
|
||||
}
|
||||
tool.settled = true
|
||||
const result = settledOutput(event.output, event.result)
|
||||
const provider = {
|
||||
executed: event.providerExecuted === true || tool.providerExecuted,
|
||||
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
|
||||
}
|
||||
const result = error ? { error } : settledOutput(event.output, event.result)
|
||||
const executed = event.providerExecuted === true || tool.providerExecuted
|
||||
const resultState = providerState(event.providerMetadata)
|
||||
if ("error" in result) {
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
|
|
@ -343,7 +368,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
callID: event.id,
|
||||
error: result.error,
|
||||
result: event.result,
|
||||
provider,
|
||||
executed,
|
||||
resultState,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
|
@ -353,12 +379,14 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
callID: event.id,
|
||||
...result,
|
||||
outputPaths,
|
||||
...(provider.executed ? { result: event.result } : {}),
|
||||
provider,
|
||||
...(executed ? { result: event.result } : {}),
|
||||
executed,
|
||||
resultState,
|
||||
})
|
||||
return
|
||||
}
|
||||
case "tool-error": {
|
||||
retryEvidence = true
|
||||
const tool = tools.get(event.id)
|
||||
if (!tool?.called) return yield* Effect.die(new Error(`Tool error before call: ${event.id}`))
|
||||
if (tool.name !== event.name)
|
||||
|
|
@ -369,25 +397,30 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
error: { type: "unknown", message: event.message },
|
||||
provider: {
|
||||
executed: tool.providerExecuted,
|
||||
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
|
||||
},
|
||||
error:
|
||||
event.message === `Unknown tool: ${event.name}`
|
||||
? { type: "tool.unknown", message: event.message }
|
||||
: { type: "tool.execution", message: event.message },
|
||||
executed: tool.providerExecuted,
|
||||
resultState: providerState(event.providerMetadata),
|
||||
})
|
||||
return
|
||||
}
|
||||
case "step-finish":
|
||||
yield* flush()
|
||||
assistantActive = false
|
||||
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
|
||||
if (event.reason === "content-filter") {
|
||||
providerFailed = true
|
||||
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true)
|
||||
return
|
||||
}
|
||||
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
|
||||
return
|
||||
case "finish":
|
||||
return
|
||||
case "provider-error":
|
||||
providerFailed = true
|
||||
yield* failAssistant(event.message)
|
||||
yield* failAssistant({ type: "provider.unknown", message: event.message }, true)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
|
@ -396,10 +429,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
publish,
|
||||
flush,
|
||||
failAssistant,
|
||||
publishStepFailure,
|
||||
failUnsettledTools,
|
||||
hasActiveAssistant: () => assistantActive,
|
||||
hasAssistantStarted: () => assistantMessageID !== undefined,
|
||||
hasProviderError: () => providerFailed,
|
||||
hasRetryEvidence: () => retryEvidence,
|
||||
stepFailure: () => stepFailure,
|
||||
stepSettlement: () => stepSettlement,
|
||||
startAssistant,
|
||||
assistantMessageID: assistantMessageIDForTool,
|
||||
|
|
|
|||
67
packages/core/src/session/runner/retry.ts
Normal file
67
packages/core/src/session/runner/retry.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
export * as SessionRunnerRetry from "./retry"
|
||||
|
||||
import { LLMError } from "@opencode-ai/llm"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Data, Duration, Effect, Schedule } from "effect"
|
||||
import { EventV2 } from "../../event"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionMessage } from "../message"
|
||||
import { SessionSchema } from "../schema"
|
||||
import type { SessionRunner } from "./index"
|
||||
|
||||
export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableFailure")<{
|
||||
readonly cause: LLMError
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly error: SessionError.Error
|
||||
readonly step: number
|
||||
}> {}
|
||||
|
||||
export function isRetryable(error: LLMError) {
|
||||
switch (error.reason._tag) {
|
||||
case "RateLimit":
|
||||
case "ProviderInternal":
|
||||
case "Transport":
|
||||
return true
|
||||
case "Authentication":
|
||||
case "QuotaExceeded":
|
||||
case "ContentPolicy":
|
||||
case "InvalidProviderOutput":
|
||||
case "InvalidRequest":
|
||||
case "NoRoute":
|
||||
case "UnknownProvider":
|
||||
return false
|
||||
default: {
|
||||
const exhaustive: never = error.reason
|
||||
return exhaustive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const retryAfter = (failure: RetryableFailure) => {
|
||||
if (failure.cause.reason._tag === "RateLimit" || failure.cause.reason._tag === "ProviderInternal")
|
||||
return failure.cause.reason.retryAfterMs
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID) =>
|
||||
Schedule.exponential("2 seconds").pipe(
|
||||
Schedule.take(4),
|
||||
Schedule.setInputType<RetryableFailure | SessionRunner.RunError>(),
|
||||
Schedule.passthrough,
|
||||
Schedule.while(({ input }) => input instanceof RetryableFailure),
|
||||
Schedule.modifyDelay((failure, delay) => {
|
||||
const minimum = failure instanceof RetryableFailure ? retryAfter(failure) : undefined
|
||||
return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)))
|
||||
}),
|
||||
Schedule.tap((metadata) =>
|
||||
metadata.input instanceof RetryableFailure
|
||||
? events.publish(SessionEvent.RetryScheduled, {
|
||||
sessionID,
|
||||
assistantMessageID: metadata.input.assistantMessageID,
|
||||
attempt: metadata.attempt + 1,
|
||||
at: metadata.now + Duration.toMillis(metadata.duration),
|
||||
error: metadata.input.error,
|
||||
})
|
||||
: Effect.void,
|
||||
),
|
||||
)
|
||||
|
|
@ -43,6 +43,11 @@ const textAttachment = (file: FileAttachment) =>
|
|||
|
||||
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
|
||||
const providerMetadata = (
|
||||
provider: string,
|
||||
state: Record<string, unknown> | undefined,
|
||||
): ProviderMetadata | undefined => (state === undefined ? undefined : { [provider]: state })
|
||||
|
||||
const toolInput = (tool: SessionMessage.AssistantTool) =>
|
||||
tool.state.status === "pending"
|
||||
? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input)
|
||||
|
|
@ -53,7 +58,7 @@ const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: Provider
|
|||
id: tool.id,
|
||||
name: tool.name,
|
||||
input: toolInput(tool),
|
||||
providerExecuted: tool.provider?.executed,
|
||||
providerExecuted: tool.executed,
|
||||
providerMetadata,
|
||||
})
|
||||
|
||||
|
|
@ -62,14 +67,14 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
|
|||
// TODO: Materialize remote and managed URIs before provider-history lowering.
|
||||
// ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes.
|
||||
const result =
|
||||
tool.provider?.executed === true && tool.state.result !== undefined
|
||||
tool.executed === true && tool.state.result !== undefined
|
||||
? tool.state.result
|
||||
: ToolOutput.toResultValue({ structured: tool.state.structured, content: tool.state.content })
|
||||
return ToolResultPart.make({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
result,
|
||||
providerExecuted: tool.provider?.executed,
|
||||
providerExecuted: tool.executed,
|
||||
providerMetadata,
|
||||
})
|
||||
}
|
||||
|
|
@ -78,11 +83,11 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
|
|||
id: tool.id,
|
||||
name: tool.name,
|
||||
result:
|
||||
tool.provider?.executed === true && tool.state.result !== undefined
|
||||
tool.executed === true && tool.state.result !== undefined
|
||||
? tool.state.result
|
||||
: { error: tool.state.error, content: tool.state.content, structured: tool.state.structured },
|
||||
resultType: "error",
|
||||
providerExecuted: tool.provider?.executed,
|
||||
providerExecuted: tool.executed,
|
||||
providerMetadata,
|
||||
})
|
||||
}
|
||||
|
|
@ -100,17 +105,22 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
|
|||
{
|
||||
type: "reasoning",
|
||||
text: item.text,
|
||||
providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined,
|
||||
providerMetadata: reuseProviderMetadata ? providerMetadata(model.providerID, item.state) : undefined,
|
||||
},
|
||||
]
|
||||
: item.text.length > 0
|
||||
? [{ type: "text", text: item.text }]
|
||||
: []
|
||||
const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined)
|
||||
if (item.provider?.executed !== true) return [call]
|
||||
const call = toolCall(
|
||||
item,
|
||||
reuseProviderMetadata ? providerMetadata(model.providerID, item.providerState) : undefined,
|
||||
)
|
||||
if (item.executed !== true) return [call]
|
||||
const result = toolResult(
|
||||
item,
|
||||
reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined,
|
||||
reuseProviderMetadata
|
||||
? providerMetadata(model.providerID, item.providerResultState ?? item.providerState)
|
||||
: undefined,
|
||||
)
|
||||
return result ? [call, result] : [call]
|
||||
})
|
||||
|
|
@ -120,9 +130,14 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
|
|||
return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
|
||||
})
|
||||
const results = message.content
|
||||
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
|
||||
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.executed !== true)
|
||||
.map((item) =>
|
||||
toolResult(item, reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined),
|
||||
toolResult(
|
||||
item,
|
||||
reuseProviderMetadata
|
||||
? providerMetadata(model.providerID, item.providerResultState ?? item.providerState)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.filter((message) => message !== undefined)
|
||||
.map(Message.tool)
|
||||
|
|
@ -141,9 +156,7 @@ function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Mess
|
|||
case "user":
|
||||
const files = message.files ?? []
|
||||
return [
|
||||
...files
|
||||
.filter((file) => file.mime === "text/plain")
|
||||
.map(textAttachment),
|
||||
...files.filter((file) => file.mime === "text/plain").map(textAttachment),
|
||||
Message.make({
|
||||
id: message.id,
|
||||
role: "user",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue