refactor(core): capture session inside loop operations
This commit is contained in:
@@ -120,7 +120,7 @@ Every live agent owns a scoped `agent.ctx`. Its registrations shadow globals, re
|
|||||||
|
|
||||||
### Initiating Agent Scope
|
### Initiating Agent Scope
|
||||||
|
|
||||||
`AgentLoop` runs each process-local driver inside `ctx.agents.withInitiator()`; the [decision](rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md) owns boundary and explicit-identity rules.
|
`AgentLoop` runs each driver inside `ctx.agents.withInitiator()`; private code derives `agent.session`, while other identities stay explicit ([decision](rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
|
||||||
|
|
||||||
## State
|
## State
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
# after editing either side, bring the other along and re-record with:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write
|
# pnpm run verify-translation-pairing --write
|
||||||
2026-07-15-agent-initiator-scope.md: adae5d943faa6df436014ef3657b56adfbc6ea60
|
2026-07-15-agent-initiator-scope.md: a9df15beb8744216e020c259934db9fdf8b28b79
|
||||||
2026-07-15-agent-initiator-scope.zh.md: a494c561c99adad9d1c44159e112b7220ea8468f
|
2026-07-15-agent-initiator-scope.zh.md: 4198f066ef27042bda0d12fbcaf86f143482d596
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ The mandatory `ctx.agents` service uses Node `AsyncLocalStorage` to carry the in
|
|||||||
|
|
||||||
`currentInitiator()` reads optionally, `requireInitiator()` throws `no initiating agent is active`, and `withInitiator(agent, operation)` preserves the operation's exact synchronous value or Promise. `withoutInitiator(operation)` establishes a clearing boundary for work that must not inherit an Agent. Session remains derived as `agent.session`; turn, step, tool call, `signal`, model, `cwd`, sandbox, and authorization stay with their existing owners.
|
`currentInitiator()` reads optionally, `requireInitiator()` throws `no initiating agent is active`, and `withInitiator(agent, operation)` preserves the operation's exact synchronous value or Promise. `withoutInitiator(operation)` establishes a clearing boundary for work that must not inherit an Agent. Session remains derived as `agent.session`; turn, step, tool call, `signal`, model, `cwd`, sandbox, and authorization stay with their existing owners.
|
||||||
|
|
||||||
`AgentLoop` already injects `ctx.agents` and wraps each concrete driver's complete `runLoop` lifetime in `agents.withInitiator(agent, ...)`. Its package-private loop, turn, step, and tool-call helpers recover the exact Agent from `ctx.agents` instead of forwarding the concrete driver through their signatures. Concurrent drivers therefore receive independent stores. A child driver's continuations carry the child, while the caller resumes in its prior store as soon as `withInitiator()` returns; active-run tracking keeps the returned Promise in the teardown drain until it settles. Creation, persistence load, and unpublished `setup(agentCtx)` remain outside the child's driver boundary: creation initiated by a parent runs under the parent identity, while `agentCtx.agent` explicitly identifies the child.
|
`AgentLoop` already injects `ctx.agents` and wraps each concrete driver's complete `runLoop` lifetime in `agents.withInitiator(agent, ...)`. Its package-private loop, turn, step, and tool-call orchestration entries recover the exact Agent from `ctx.agents`, derive `agent.session` once, and let operation-local helpers capture it instead of forwarding the concrete driver or `Session` through shallow interfaces. A leaf helper keeps a narrow `Session` parameter when that is its actual interface rather than accepting a broader `Context` only for an ambient lookup.
|
||||||
|
|
||||||
|
Concurrent drivers receive independent stores. A child driver's continuations carry the child, while the caller resumes in its prior store as soon as `withInitiator()` returns; active-run tracking keeps the returned Promise in the teardown drain until it settles. Creation, persistence load, and unpublished `setup(agentCtx)` remain outside the child's driver boundary: creation initiated by a parent runs under the parent identity, while `agentCtx.agent` explicitly identifies the child.
|
||||||
|
|
||||||
Ambient identity does not replace explicit contracts. `ToolExecution.agent`, `AssembleContext.agent`, `GenerateOptions.sessionId`, task ownership, parent/child requests, `ctx.agent`, `agentCtx.agent`, approval and hook subjects, `cwd` selection, cancellation, worker/process messages, persistence records, and wire identity remain explicit. A remote boundary materializes the identity it needs into its typed request because ALS is process-local.
|
Ambient identity does not replace explicit contracts. `ToolExecution.agent`, `AssembleContext.agent`, `GenerateOptions.sessionId`, task ownership, parent/child requests, `ctx.agent`, `agentCtx.agent`, approval and hook subjects, `cwd` selection, cancellation, worker/process messages, persistence records, and wire identity remain explicit. A remote boundary materializes the identity it needs into its typed request because ALS is process-local.
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负
|
|||||||
|
|
||||||
`currentInitiator()` 用于可选读取,`requireInitiator()` 抛出 `no initiating agent is active`,`withInitiator(agent, operation)` 保留操作返回的同步值或 Promise 本身。`withoutInitiator(operation)` 会建立清空边界,供不得继承 Agent 的工作使用。会话仍通过 `agent.session` 推导;轮次、步骤、工具调用、`signal`、模型、`cwd`、沙箱和授权继续由现有归属方管理。
|
`currentInitiator()` 用于可选读取,`requireInitiator()` 抛出 `no initiating agent is active`,`withInitiator(agent, operation)` 保留操作返回的同步值或 Promise 本身。`withoutInitiator(operation)` 会建立清空边界,供不得继承 Agent 的工作使用。会话仍通过 `agent.session` 推导;轮次、步骤、工具调用、`signal`、模型、`cwd`、沙箱和授权继续由现有归属方管理。
|
||||||
|
|
||||||
`AgentLoop` 已经注入 `ctx.agents`,并用 `agents.withInitiator(agent, ...)` 包裹每个具体驱动的完整 `runLoop` 生命周期。其包内私有的循环、轮次、步骤和工具调用辅助函数从 `ctx.agents` 恢复同一个 Agent,无需在函数签名中转发具体驱动。因此,并发驱动使用彼此独立的存储。子驱动的异步延续携带子 Agent;`withInitiator()` 返回后,调用方立即恢复之前的存储,而活动运行计数仍持续跟踪返回的 Promise,直到其结束。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。
|
`AgentLoop` 已经注入 `ctx.agents`,并用 `agents.withInitiator(agent, ...)` 包裹每个具体驱动的完整 `runLoop` 生命周期。循环、轮次、步骤和工具调用的包内私有入口从 `ctx.agents` 恢复同一个 Agent,一次推导 `agent.session`,再由操作内辅助函数捕获该值,避免在浅层接口中转发具体驱动或 `Session`。若 `Session` 本身就是底层辅助函数的实际接口,该函数会保留狭窄的 `Session` 参数,而不会只为隐式查找而接收更宽泛的 `Context`。
|
||||||
|
|
||||||
|
因此,并发驱动使用彼此独立的存储。子驱动的异步延续携带子 Agent;`withInitiator()` 返回后,调用方立即恢复之前的存储,而活动运行计数仍持续跟踪返回的 Promise,直到其结束。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。
|
||||||
|
|
||||||
隐式身份不会取代显式契约。`ToolExecution.agent`、`AssembleContext.agent`、`GenerateOptions.sessionId`、任务归属、父子请求、`ctx.agent`、`agentCtx.agent`、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。
|
隐式身份不会取代显式契约。`ToolExecution.agent`、`AssembleContext.agent`、`GenerateOptions.sessionId`、任务归属、父子请求、`ctx.agent`、`agentCtx.agent`、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publicati
|
|||||||
|
|
||||||
### Loop lifecycle (`loop.ts`)
|
### Loop lifecycle (`loop.ts`)
|
||||||
|
|
||||||
The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`, so package-private loop, turn, step, and tool-call helpers recover the exact Agent from `ctx.agents` instead of forwarding the concrete driver through their signatures. Creation, persistence load, and unpublished setup stay outside the driver boundary; explicit Agent fields remain authoritative at service, worker, process, persistence, and wire boundaries. The [agent service](../agent/README.md#initiating-agent-scope) owns propagation, teardown, and detached-work rules.
|
The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. Package-private orchestration entry points recover the exact Agent, derive `agent.session` once, and let operation-local helpers capture it instead of forwarding the concrete driver or per-operation `Session` through shallow interfaces. A helper keeps an explicit `Session` when that is its actual interface, while creation, persistence load, unpublished setup, services, workers, processes, persistence, and wire protocols retain their explicit identities. The [agent service](../agent/README.md#initiating-agent-scope) owns propagation, teardown, and detached-work rules.
|
||||||
|
|
||||||
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history.
|
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history.
|
||||||
|
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ export interface LoopHandle {
|
|||||||
/**
|
/**
|
||||||
* Drive queued batches as durable turns until disposal. Plugin failures end the
|
* Drive queued batches as durable turns until disposal. Plugin failures end the
|
||||||
* current turn without terminating the driver. The caller establishes the
|
* current turn without terminating the driver. The caller establishes the
|
||||||
* `ctx.agents.withInitiator()` boundary before entry; package-private helpers
|
* `ctx.agents.withInitiator()` boundary before entry; package-private
|
||||||
* recover that exact Agent from the inherited store.
|
* orchestration recovers that exact Agent and captures its Session locally.
|
||||||
* @param ctx - the plugin context the loop reaches its initiating Agent,
|
* @param ctx - the plugin context the loop reaches its initiating Agent,
|
||||||
* events (agent/…, session/flush), and services (systemPrompt, llm, tools)
|
* events (agent/…, session/flush), and services (systemPrompt, llm, tools)
|
||||||
* through.
|
* through.
|
||||||
@@ -169,6 +169,13 @@ async function runTurn(
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const agent = ctx.agents.requireInitiator()
|
const agent = ctx.agents.requireInitiator()
|
||||||
const { session } = agent
|
const { session } = agent
|
||||||
|
const drainSteering = (): boolean => {
|
||||||
|
const messages = handle.inbox.drainSteering()
|
||||||
|
for (const message of messages) {
|
||||||
|
session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
|
||||||
|
}
|
||||||
|
return messages.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
// Drain before opening the turn, but append only after `turn/start`.
|
// Drain before opening the turn, but append only after `turn/start`.
|
||||||
const queued = handle.inbox.drainQueued()
|
const queued = handle.inbox.drainQueued()
|
||||||
@@ -267,7 +274,7 @@ async function runTurn(
|
|||||||
|
|
||||||
// Steering from the previous round's continuation listeners joins before
|
// Steering from the previous round's continuation listeners joins before
|
||||||
// the request.
|
// the request.
|
||||||
drainSteering(session, handle.inbox, turn)
|
drainSteering()
|
||||||
|
|
||||||
// The step's AbortController exists BEFORE any async pre-step work so a
|
// The step's AbortController exists BEFORE any async pre-step work so a
|
||||||
// dispose() or cancel() — in a synchronous turn-start listener or an
|
// dispose() or cancel() — in a synchronous turn-start listener or an
|
||||||
@@ -370,7 +377,7 @@ async function runTurn(
|
|||||||
if (stepReason) reason = stepReason
|
if (stepReason) reason = stepReason
|
||||||
|
|
||||||
// Steering that arrived during streaming/tool execution.
|
// Steering that arrived during streaming/tool execution.
|
||||||
const steered = drainSteering(session, handle.inbox, turn)
|
const steered = drainSteering()
|
||||||
|
|
||||||
closeStep()
|
closeStep()
|
||||||
|
|
||||||
@@ -459,15 +466,6 @@ async function runTurn(
|
|||||||
return terminalStopped
|
return terminalStopped
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drain the steering queue into the session. Returns whether any arrived. */
|
|
||||||
function drainSteering(session: Session, inbox: Inbox, turn: number): boolean {
|
|
||||||
const messages = inbox.drainSteering()
|
|
||||||
for (const message of messages) {
|
|
||||||
session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
|
|
||||||
}
|
|
||||||
return messages.length > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run one committed step: transform call config, log the request header, build
|
* Run one committed step: transform call config, log the request header, build
|
||||||
* the request from the cached prefix plus the step-boundary snapshot, stream and
|
* the request from the cached prefix plus the step-boundary snapshot, stream and
|
||||||
@@ -543,15 +541,47 @@ async function runStep(
|
|||||||
const stepError = finishError(assembler.finish)
|
const stepError = finishError(assembler.finish)
|
||||||
if (stepError) throw stepError
|
if (stepError) throw stepError
|
||||||
|
|
||||||
|
const recordAssistantMessage = (
|
||||||
|
assembledContent: ContentBlock[],
|
||||||
|
message: Message,
|
||||||
|
preserveReplayState = true,
|
||||||
|
): void => {
|
||||||
|
session.append(
|
||||||
|
'assistant/message',
|
||||||
|
{
|
||||||
|
turn,
|
||||||
|
step,
|
||||||
|
content: message.content,
|
||||||
|
provenance: assistantProvenance(
|
||||||
|
header.config,
|
||||||
|
assembler.replayState,
|
||||||
|
preserveReplayState && isDeepStrictEqual(message.content, assembledContent),
|
||||||
|
),
|
||||||
|
...assembler.usage === undefined ? {} : { usage: assembler.usage },
|
||||||
|
},
|
||||||
|
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rejected result still records the successful provider call without retaining rejected output.
|
||||||
|
const processStepResult = async (assembledContent: ContentBlock[], message: Message): Promise<Message> => {
|
||||||
|
try {
|
||||||
|
return await events.waterfall(
|
||||||
|
'agent/step-result', turn, step, message, () => Promise.resolve(message),
|
||||||
|
)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
recordAssistantMessage(assembledContent, { ...message, content: [] }, false)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (assembler.finish.kind === 'max-tokens') {
|
if (assembler.finish.kind === 'max-tokens') {
|
||||||
const assembled = assembler.message()
|
const assembled = assembler.message()
|
||||||
const assembledContent = structuredClone(assembled.content)
|
const assembledContent = structuredClone(assembled.content)
|
||||||
let message: Message = withoutToolCalls(assembled)
|
let message: Message = withoutToolCalls(assembled)
|
||||||
message = withoutToolCalls(await processStepResult(
|
message = withoutToolCalls(await processStepResult(assembledContent, message))
|
||||||
events, session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs,
|
|
||||||
))
|
|
||||||
// Preserve usage even when max-token truncation produced no content.
|
// Preserve usage even when max-token truncation produced no content.
|
||||||
recordAssistantMessage(session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs)
|
recordAssistantMessage(assembledContent, message)
|
||||||
return { hadToolCalls: false, finish: assembler.finish }
|
return { hadToolCalls: false, finish: assembler.finish }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,13 +589,11 @@ async function runStep(
|
|||||||
const assembled = assembler.message()
|
const assembled = assembler.message()
|
||||||
const assembledContent = structuredClone(assembled.content)
|
const assembledContent = structuredClone(assembled.content)
|
||||||
let message: Message = assembled
|
let message: Message = assembled
|
||||||
message = await processStepResult(
|
message = await processStepResult(assembledContent, message)
|
||||||
events, session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Every successful call records its completion anchor, including explicit
|
// Every successful call records its completion anchor, including explicit
|
||||||
// empty chunk provenance for a contentless, usage-less provider response.
|
// empty chunk provenance for a contentless, usage-less provider response.
|
||||||
recordAssistantMessage(session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs)
|
recordAssistantMessage(assembledContent, message)
|
||||||
|
|
||||||
// Dispatch may overlap; policy, durable results, and result context stay model-ordered.
|
// Dispatch may overlap; policy, durable results, and result context stay model-ordered.
|
||||||
const toolCalls = message.content.filter(block => block.type === 'tool-call')
|
const toolCalls = message.content.filter(block => block.type === 'tool-call')
|
||||||
@@ -578,67 +606,6 @@ async function runStep(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Preserve successful-call accounting without retaining output that result processing rejected. */
|
|
||||||
async function processStepResult(
|
|
||||||
events: AgentEventDispatch,
|
|
||||||
session: Session,
|
|
||||||
turn: number,
|
|
||||||
step: number,
|
|
||||||
config: LlmCallConfig,
|
|
||||||
assembledContent: ContentBlock[],
|
|
||||||
message: Message,
|
|
||||||
assembler: BlockAssembler,
|
|
||||||
chunkSeqs: number[],
|
|
||||||
): Promise<Message> {
|
|
||||||
try {
|
|
||||||
return await events.waterfall(
|
|
||||||
'agent/step-result', turn, step, message, () => Promise.resolve(message),
|
|
||||||
)
|
|
||||||
} catch (error: unknown) {
|
|
||||||
recordAssistantMessage(
|
|
||||||
session,
|
|
||||||
turn,
|
|
||||||
step,
|
|
||||||
config,
|
|
||||||
assembledContent,
|
|
||||||
{ ...message, content: [] },
|
|
||||||
assembler,
|
|
||||||
chunkSeqs,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Record one content-or-usage assistant message with replay-safe provenance. */
|
|
||||||
function recordAssistantMessage(
|
|
||||||
session: Session,
|
|
||||||
turn: number,
|
|
||||||
step: number,
|
|
||||||
config: LlmCallConfig,
|
|
||||||
assembledContent: ContentBlock[],
|
|
||||||
message: Message,
|
|
||||||
assembler: BlockAssembler,
|
|
||||||
chunkSeqs: number[],
|
|
||||||
preserveReplayState = true,
|
|
||||||
): void {
|
|
||||||
session.append(
|
|
||||||
'assistant/message',
|
|
||||||
{
|
|
||||||
turn,
|
|
||||||
step,
|
|
||||||
content: message.content,
|
|
||||||
provenance: assistantProvenance(
|
|
||||||
config,
|
|
||||||
assembler.replayState,
|
|
||||||
preserveReplayState && isDeepStrictEqual(message.content, assembledContent),
|
|
||||||
),
|
|
||||||
...assembler.usage === undefined ? {} : { usage: assembler.usage },
|
|
||||||
},
|
|
||||||
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build durable assistant provenance, dropping replay state after any content rewrite. */
|
/** Build durable assistant provenance, dropping replay state after any content rewrite. */
|
||||||
function assistantProvenance(config: LlmCallConfig, replayState: unknown, contentUnchanged: boolean): NonNullable<Message['provenance']> {
|
function assistantProvenance(config: LlmCallConfig, replayState: unknown, contentUnchanged: boolean): NonNullable<Message['provenance']> {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
|
import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
|
||||||
import type { HookContext } from '@deepseek-ai/dsh-agent'
|
import type { HookContext } from '@deepseek-ai/dsh-agent'
|
||||||
import type { Session } from '@deepseek-ai/dsh-session'
|
|
||||||
import { TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
|
import { TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||||
|
|
||||||
/** One tool call after argument parsing, ready to schedule. */
|
/** One tool call after argument parsing, ready to schedule. */
|
||||||
@@ -104,6 +103,29 @@ async function runGroup(
|
|||||||
acceptContext: (context: HookContext) => void,
|
acceptContext: (context: HookContext) => void,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const { session } = ctx.agents.requireInitiator()
|
const { session } = ctx.agents.requireInitiator()
|
||||||
|
const appendToolCall = (block: ToolCallBlock): number => {
|
||||||
|
return session.append('tool/call', {
|
||||||
|
turn,
|
||||||
|
step,
|
||||||
|
callId: block.id,
|
||||||
|
name: block.name,
|
||||||
|
arguments: block.arguments,
|
||||||
|
}).seq
|
||||||
|
}
|
||||||
|
const appendToolResult = (block: ToolCallBlock, result: ToolExecutionResult, callSeq: number): void => {
|
||||||
|
session.append('tool/result', {
|
||||||
|
turn,
|
||||||
|
step,
|
||||||
|
// Correlation stays with the loop's authoritative model-transcript call id;
|
||||||
|
// registry results deliberately do not duplicate it.
|
||||||
|
callId: block.id,
|
||||||
|
content: result.content,
|
||||||
|
isError: result.isError,
|
||||||
|
...result.error ? { error: result.error } : {},
|
||||||
|
// Persist presentation payloads so UI bridges reproduce result cards on replay.
|
||||||
|
...result.meta !== undefined ? { meta: result.meta } : {},
|
||||||
|
}, { surfaceOp: 'append', sourceEventSeqs: [callSeq] })
|
||||||
|
}
|
||||||
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
||||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||||
const slots: (Slot | undefined)[] = group.map(() => undefined)
|
const slots: (Slot | undefined)[] = group.map(() => undefined)
|
||||||
@@ -124,7 +146,7 @@ async function runGroup(
|
|||||||
? await ctx.tools[TOOL_REGISTRY_SCHEDULER].finalize(slot.exec, slot.result)
|
? await ctx.tools[TOOL_REGISTRY_SCHEDULER].finalize(slot.exec, slot.result)
|
||||||
: ctx.tools[TOOL_REGISTRY_SCHEDULER].finish(slot.exec, slot.result)
|
: ctx.tools[TOOL_REGISTRY_SCHEDULER].finish(slot.exec, slot.result)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
|
||||||
appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!)
|
appendToolResult(call!.block, result, callSeqs[committed]!)
|
||||||
for (const context of result.additionalContexts ?? []) acceptContext(context)
|
for (const context of result.additionalContexts ?? []) acceptContext(context)
|
||||||
committed++
|
committed++
|
||||||
}
|
}
|
||||||
@@ -135,7 +157,7 @@ async function runGroup(
|
|||||||
const startCall = async (index: number): Promise<void> => {
|
const startCall = async (index: number): Promise<void> => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
|
||||||
const call = group[index]!
|
const call = group[index]!
|
||||||
callSeqs[index] = appendToolCall(session, turn, step, call.block)
|
callSeqs[index] = appendToolCall(call.block)
|
||||||
started++
|
started++
|
||||||
const prepared = await ctx.tools[TOOL_REGISTRY_SCHEDULER].prepare(call.exec)
|
const prepared = await ctx.tools[TOOL_REGISTRY_SCHEDULER].prepare(call.exec)
|
||||||
switch (prepared.kind) {
|
switch (prepared.kind) {
|
||||||
@@ -197,32 +219,3 @@ async function runGroup(
|
|||||||
if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls')
|
if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls')
|
||||||
return started
|
return started
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Append a started call and return its provenance sequence. */
|
|
||||||
function appendToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): number {
|
|
||||||
const event = session.append('tool/call', { turn, step, callId: block.id, name: block.name, arguments: block.arguments })
|
|
||||||
return event.seq
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Append a model-ordered result linked to its call event. */
|
|
||||||
function appendToolResult(
|
|
||||||
session: Session,
|
|
||||||
turn: number,
|
|
||||||
step: number,
|
|
||||||
block: ToolCallBlock,
|
|
||||||
result: ToolExecutionResult,
|
|
||||||
callSeq: number,
|
|
||||||
): void {
|
|
||||||
session.append('tool/result', {
|
|
||||||
turn, step,
|
|
||||||
// Correlation stays with the loop's authoritative model-transcript call id;
|
|
||||||
// registry results deliberately do not duplicate it.
|
|
||||||
callId: block.id,
|
|
||||||
content: result.content,
|
|
||||||
isError: result.isError,
|
|
||||||
...result.error ? { error: result.error } : {},
|
|
||||||
// The tool's private presentation payload (e.g. a result-time diff),
|
|
||||||
// persisted so a UI bridge reproduces the card on replay.
|
|
||||||
...result.meta !== undefined ? { meta: result.meta } : {},
|
|
||||||
}, { surfaceOp: 'append', sourceEventSeqs: [callSeq] })
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user