refactor(core): use initiator in loop internals
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# 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:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-15-agent-initiator-scope.md: 480bb56f811567406e964ca94fca04fd3d4ace62
|
||||
2026-07-15-agent-initiator-scope.zh.md: 29ea3a47f42d607a4584b888ace232de124e7598
|
||||
2026-07-15-agent-initiator-scope.md: 61aad24c13d66f206fed81ae6eb074666b23d0d2
|
||||
2026-07-15-agent-initiator-scope.zh.md: e420e3cd5fa4b2b2f868be11080c37f29b2ce313
|
||||
|
||||
@@ -16,7 +16,7 @@ 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.
|
||||
|
||||
`AgentLoop` already injects `ctx.agents` and wraps each concrete driver's complete `runLoop` lifetime in `agents.withInitiator(agent, ...)`. Concurrent drivers therefore receive independent stores, a child driver shadows its parent, and the parent store returns when the child boundary 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 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 shadows its parent, and the parent store returns when the child boundary 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.
|
||||
|
||||
@@ -30,9 +30,9 @@ This decision extends the [Agent registration-scope contract](2026-07-08-agent-s
|
||||
|
||||
## Verification
|
||||
|
||||
Agent service tests pin optional and required reads, exact synchronous and cross-realm Promise identity, overlapping, nested, and cleared boundaries, restoration after throws or rejection, drain ordering, and retained-reference errors. AgentLoop integration pins concurrent and nested drivers, agentless calls, AgentRegistry restart, and root teardown. Composition, module-graph, build, and runtime-closure checks keep `ctx.agents` wired through the default bundle, SDK spine, Python runtime closure, and direct AgentLoop harnesses without another provider.
|
||||
Agent service tests pin optional and required reads, exact synchronous and cross-realm Promise identity, overlapping, nested, and cleared boundaries, restoration after throws or rejection, drain ordering, and retained-reference errors. AgentLoop integration pins concurrent and nested drivers, agentless calls, AgentRegistry restart, root teardown, and package-private loop and tool scheduling through the ambient lookup. Composition, module-graph, build, and runtime-closure checks keep `ctx.agents` wired through the default bundle, SDK spine, Python runtime closure, and direct AgentLoop harnesses without another provider.
|
||||
|
||||
Only a test-double host-aware transport consumes ambient identity; it derives `X-Harness-Session-Id` internally and verifies that tool schema and logged arguments contain no identity field. The service deliberately does not drain async work omitted from the Promise returned by the boundary operation; that work remains subject to its owner's explicit stop contract.
|
||||
A test-double host-aware transport derives `X-Harness-Session-Id` internally and verifies that tool schema and logged arguments contain no identity field. The service deliberately does not drain async work omitted from the Promise returned by the boundary operation; that work remains subject to its owner's explicit stop contract.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负
|
||||
|
||||
`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` 生命周期。因此,并发驱动使用彼此独立的存储,子驱动会遮蔽父驱动,子边界结束后父存储得到恢复。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。
|
||||
`AgentLoop` 已经注入 `ctx.agents`,并用 `agents.withInitiator(agent, ...)` 包裹每个具体驱动的完整 `runLoop` 生命周期。其包内私有的循环、轮次、步骤和工具调用辅助函数从 `ctx.agents` 恢复同一个 Agent,无需在函数签名中转发具体驱动。因此,并发驱动使用彼此独立的存储,子驱动会遮蔽父驱动,子边界结束后父存储得到恢复。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。
|
||||
|
||||
隐式身份不会取代显式契约。`ToolExecution.agent`、`AssembleContext.agent`、`GenerateOptions.sessionId`、任务归属、父子请求、`ctx.agent`、`agentCtx.agent`、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。
|
||||
|
||||
@@ -30,9 +30,9 @@ Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负
|
||||
|
||||
## 验证
|
||||
|
||||
Agent 服务测试锁定可选与必需读取、同步值和跨 realm Promise 的引用身份、并发、嵌套及清空边界、同步抛错或 Promise 拒绝后的恢复、排空顺序及保留引用的错误。AgentLoop 集成测试锁定并发与嵌套驱动、无 Agent 调用、AgentRegistry 重启及根 Context 销毁。组合、模块图、构建及运行时闭包检查确保默认组合包、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 通过 `ctx.agents` 完成接线,无需其他提供方。
|
||||
Agent 服务测试锁定可选与必需读取、同步值和跨 realm Promise 的引用身份、并发、嵌套及清空边界、同步抛错或 Promise 拒绝后的恢复、排空顺序及保留引用的错误。AgentLoop 集成测试锁定并发与嵌套驱动、无 Agent 调用、AgentRegistry 重启、根 Context 销毁,以及包内私有的循环和工具调度通过隐式查找完成。组合、模块图、构建及运行时闭包检查确保默认组合包、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 通过 `ctx.agents` 完成接线,无需其他提供方。
|
||||
|
||||
只有测试替身形式的宿主感知传输层消费隐式身份;它在内部推导 `X-Harness-Session-Id`,并验证工具 schema 与记录参数都不包含身份字段。服务有意不排空边界操作所返回 Promise 之外的异步工作;这类工作仍由所属方的显式停止契约管理。
|
||||
测试替身形式的宿主感知传输层在内部推导 `X-Harness-Session-Id`,并验证工具 schema 与记录参数都不包含身份字段。服务有意不排空边界操作所返回 Promise 之外的异步工作;这类工作仍由所属方的显式停止契约管理。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publicati
|
||||
|
||||
### Loop lifecycle (`loop.ts`)
|
||||
|
||||
The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`, so process-local asynchronous continuations can recover the initiating Agent. 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, ...)`, 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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -387,7 +387,7 @@ export class ReactLoopAgent implements Agent {
|
||||
[startDriver](): void {
|
||||
if (this._status === 'disposed') return
|
||||
this.driverStarted = true
|
||||
this.done = this.loopCtx.agents.withInitiator(this, () => runLoop(this.loopCtx, this, {
|
||||
this.done = this.loopCtx.agents.withInitiator(this, () => runLoop(this.loopCtx, {
|
||||
inbox: this.#inbox,
|
||||
maxParallelToolCalls: this.maxParallelToolCalls,
|
||||
setStatus: (status) => { this.setStatus(status) },
|
||||
|
||||
@@ -19,7 +19,6 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import { executeToolCalls } from './tool-calls.ts'
|
||||
import type { ReactLoopAgent } from './agent.ts'
|
||||
import type { Inbox } from './inbox.ts'
|
||||
|
||||
/** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
|
||||
@@ -96,11 +95,13 @@ export interface LoopHandle {
|
||||
/**
|
||||
* Drive queued batches as durable turns until disposal. Plugin failures end the
|
||||
* current turn without terminating the driver.
|
||||
* @param ctx - the plugin context the loop reaches events (agent/…, session/flush) and services (systemPrompt, llm, tools) through.
|
||||
* @param agent - the agent this invocation drives for its whole lifetime (its inbox, session, and options).
|
||||
* @param ctx - the plugin context the loop reaches its initiating Agent,
|
||||
* events (agent/…, session/flush), and services (systemPrompt, llm, tools)
|
||||
* through.
|
||||
* @param handle - the bridge to the agent's mutable state: status/abort setters plus the disposal and cancel-marker reads.
|
||||
*/
|
||||
export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise<void> {
|
||||
export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
// Per-instance prefix and request-header state; conversation history remains in the session log.
|
||||
const transmission = createTransmissionLog()
|
||||
|
||||
@@ -138,7 +139,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
const turn = lastTurnNumber(session) + 1
|
||||
let terminalStopped = false
|
||||
try {
|
||||
terminalStopped = await runTurn(ctx, events, agent, handle, turn, transmission)
|
||||
terminalStopped = await runTurn(ctx, events, handle, turn, transmission)
|
||||
} catch (error: unknown) {
|
||||
// Pre-turn failure has no durable boundary to close; report it without appending outside a turn.
|
||||
const err = toError(error)
|
||||
@@ -161,8 +162,9 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
}
|
||||
|
||||
async function runTurn(
|
||||
ctx: Context, events: AgentEventDispatch, agent: ReactLoopAgent, handle: LoopHandle, turn: number, transmission: TransmissionLog,
|
||||
ctx: Context, events: AgentEventDispatch, handle: LoopHandle, turn: number, transmission: TransmissionLog,
|
||||
): Promise<boolean> {
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
const { session } = agent
|
||||
|
||||
// Drain before opening the turn, but append only after `turn/start`.
|
||||
@@ -262,7 +264,7 @@ async function runTurn(
|
||||
|
||||
// Steering from the previous round's continuation listeners joins before
|
||||
// the request.
|
||||
drainSteering(agent, handle.inbox, turn)
|
||||
drainSteering(session, handle.inbox, turn)
|
||||
|
||||
// The step's AbortController exists BEFORE any async pre-step work so a
|
||||
// dispose() or cancel() — in a synchronous turn-start listener or an
|
||||
@@ -336,7 +338,7 @@ async function runTurn(
|
||||
let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error }
|
||||
try {
|
||||
stepOutcome = await runStep(
|
||||
ctx, events, agent, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal)
|
||||
ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal)
|
||||
} catch (error: unknown) {
|
||||
stepOutcome = { error: toError(error) }
|
||||
} finally {
|
||||
@@ -365,7 +367,7 @@ async function runTurn(
|
||||
if (stepReason) reason = stepReason
|
||||
|
||||
// Steering that arrived during streaming/tool execution.
|
||||
const steered = drainSteering(agent, handle.inbox, turn)
|
||||
const steered = drainSteering(session, handle.inbox, turn)
|
||||
|
||||
closeStep()
|
||||
|
||||
@@ -455,10 +457,10 @@ async function runTurn(
|
||||
}
|
||||
|
||||
/** Drain the steering queue into the session. Returns whether any arrived. */
|
||||
function drainSteering(agent: ReactLoopAgent, inbox: Inbox, turn: number): boolean {
|
||||
function drainSteering(session: Session, inbox: Inbox, turn: number): boolean {
|
||||
const messages = inbox.drainSteering()
|
||||
for (const message of messages) {
|
||||
agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
|
||||
session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
|
||||
}
|
||||
return messages.length > 0
|
||||
}
|
||||
@@ -472,7 +474,6 @@ function drainSteering(agent: ReactLoopAgent, inbox: Inbox, turn: number): boole
|
||||
async function runStep(
|
||||
ctx: Context,
|
||||
events: AgentEventDispatch,
|
||||
agent: ReactLoopAgent,
|
||||
handle: LoopHandle,
|
||||
turn: number,
|
||||
step: number,
|
||||
@@ -482,6 +483,7 @@ async function runStep(
|
||||
transmission: TransmissionLog,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ hadToolCalls: boolean; finish: FinishReason }> {
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
const { session, options } = agent
|
||||
|
||||
// Seed the first request from agent options and later requests from the logged header;
|
||||
@@ -567,7 +569,7 @@ async function runStep(
|
||||
if (toolCalls.length === 0) return { hadToolCalls: false, finish: assembler.finish }
|
||||
return handle.withToolBatch(async (acceptContext) => {
|
||||
await executeToolCalls(
|
||||
ctx, agent, turn, step, toolCalls, signal, handle.maxParallelToolCalls, acceptContext,
|
||||
ctx, turn, step, toolCalls, signal, handle.maxParallelToolCalls, acceptContext,
|
||||
)
|
||||
return { hadToolCalls: true, finish: assembler.finish }
|
||||
})
|
||||
|
||||
@@ -14,7 +14,6 @@ import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
|
||||
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 type { ReactLoopAgent } from './agent.ts'
|
||||
|
||||
/** One tool call after argument parsing, ready to schedule. */
|
||||
interface PlannedCall {
|
||||
@@ -35,7 +34,6 @@ interface Slot {
|
||||
* accepting their context into the batch FIFO owned by the caller.
|
||||
*
|
||||
* @param ctx - loop context that owns the tool registry.
|
||||
* @param agent - agent and session receiving the call lifecycle.
|
||||
* @param turn - current turn number.
|
||||
* @param step - current step number.
|
||||
* @param toolCalls - assistant calls in model order.
|
||||
@@ -45,7 +43,6 @@ interface Slot {
|
||||
*/
|
||||
export async function executeToolCalls(
|
||||
ctx: Context,
|
||||
agent: ReactLoopAgent,
|
||||
turn: number,
|
||||
step: number,
|
||||
toolCalls: ToolCallBlock[],
|
||||
@@ -53,7 +50,7 @@ export async function executeToolCalls(
|
||||
maxParallel: number,
|
||||
acceptContext: (context: HookContext) => void,
|
||||
): Promise<void> {
|
||||
const { session } = agent
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
|
||||
// Inputs are distinct because tools/execute wrappers may replace `exec.signal`.
|
||||
const planned: PlannedCall[] = toolCalls.map(block => ({
|
||||
@@ -74,7 +71,7 @@ export async function executeToolCalls(
|
||||
const first = planned[next]!
|
||||
const mode = ctx.tools.executionMode(first.exec).kind
|
||||
const group = mode === 'parallel' ? planned.slice(next) : [first]
|
||||
next += await runGroup(ctx, session, turn, step, group, mode, signal, maxParallel, acceptContext)
|
||||
next += await runGroup(ctx, turn, step, group, mode, signal, maxParallel, acceptContext)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +93,6 @@ function parseArguments(raw: string): unknown {
|
||||
*/
|
||||
async function runGroup(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
turn: number,
|
||||
step: number,
|
||||
group: PlannedCall[],
|
||||
@@ -105,6 +101,7 @@ async function runGroup(
|
||||
maxParallel: number,
|
||||
acceptContext: (context: HookContext) => void,
|
||||
): Promise<number> {
|
||||
const { session } = ctx.agents.requireInitiator()
|
||||
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||
const slots: (Slot | undefined)[] = group.map(() => undefined)
|
||||
|
||||
Reference in New Issue
Block a user