Merge remote-tracking branch 'origin/master' into codex/status-bar-token-metrics
# Conflicts: # docs/cordis-catalog/events.md # docs/event-producer-consumer.md # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/runtime/tests/fake-api.ts # packages/client/runtime/tests/session.spec.ts # packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx # packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx # packages/client/ui-conversation/tests/chat-view.spec.tsx # packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx # packages/client/ui-conversation/tests/queue-dock.spec.tsx # packages/core/agent-loop/README.i18n.yaml # packages/core/agent/README.i18n.yaml # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/events.schema.ts # packages/host/apiproxy/src/api/events.ts # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/src/api/sessions.schema.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts
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 packages/core/agent-loop/README.md
|
||||
README.md: 6e23d9f543998d5c0266c73197ee52ede51260aa
|
||||
README.zh.md: cf561a03281f80b7afd5f245752e9ebf70333a8b
|
||||
README.md: b686e796c33f24d98560c212af6de0180d67993b
|
||||
README.zh.md: f6596fa3721e6f39844f73856554e6082f27a53d
|
||||
|
||||
@@ -42,13 +42,14 @@ interface Config {
|
||||
id: string // required
|
||||
provider?: string
|
||||
model?: string
|
||||
maxTokens?: number // positive per-request output-token cap
|
||||
resumeSessionId?: string // load this persisted session instead of creating one
|
||||
cwd?: string // optional workspace cwd for the fresh session
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`.
|
||||
Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. An optional positive `maxTokens` seeds each conversation request's output cap and is logged in its request header. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`.
|
||||
|
||||
### Internal concrete driver
|
||||
|
||||
|
||||
@@ -42,13 +42,14 @@ interface Config {
|
||||
id: string // required
|
||||
provider?: string
|
||||
model?: string
|
||||
maxTokens?: number // positive per-request output-token cap
|
||||
resumeSessionId?: string // load this persisted session instead of creating one
|
||||
cwd?: string // optional workspace cwd for the fresh session
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
通过配置创建的 agent 会自动启动。模型调用同时需要 `provider` 和 `model`;`agent/request` 可以在分发前补齐缺失的这一对值。`maxParallelToolCalls` 限制每个 agent 针对并行安全调用使用的滚动池,默认值为 `10`。`cwd` 仅应用于全新会话,而 `resumeSessionId` 保留持久化元数据。通过配置创建的 agent 使用部署 persona;编程式 setup 可以按 agent 遮蔽它。该插件提供逐 agent 的 `provider`、`model` 和 `cwd` 提示词变量;harness 身份与部署 persona 属于 `dsh-system-prompt`。
|
||||
通过配置创建的 agent 会自动启动。模型调用同时需要 `provider` 和 `model`;`agent/request` 可以在分发前补齐缺失的这一对值。可选的正整数 `maxTokens` 会为每次对话请求提供初始输出上限,并记录在请求 header 中。`maxParallelToolCalls` 限制每个 agent 针对并行安全调用使用的滚动池,默认值为 `10`。`cwd` 仅应用于全新会话,而 `resumeSessionId` 保留持久化元数据。通过配置创建的 agent 使用部署 persona;编程式 setup 可以按 agent 遮蔽它。该插件提供逐 agent 的 `provider`、`model` 和 `cwd` 提示词变量;harness 身份与部署 persona 属于 `dsh-system-prompt`。
|
||||
|
||||
### 包内部实体驱动器
|
||||
|
||||
@@ -62,7 +63,7 @@ interface Config {
|
||||
|
||||
每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,保留确切的 chunk 溯源(流没有 chunk 时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。
|
||||
|
||||
在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度、填入其配置默认值,并从同一次精确模型查询中保留可用的上下文元数据。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。最终提供方/模型确定且外层 `llm/stream` 调用返回句柄后,循环会发出一条失败会被收容的实时 `agent/model-request` 通知,其中包含轮次、步骤、路由,以及可选的、与注册项绑定的容量。这是 agent loop 观察到的一次尝试,并不能证明提供方 I/O 已开始:准备阶段或外层 waterfall(瀑布式事件)的同步失败不会发出通知,而短路句柄或之后的惰性适配器构造、失败或中止仍会计入。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。
|
||||
在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度、填入其配置默认值,并从同一次精确模型查询中保留可用的上下文元数据。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。最终提供方/模型确定且外层 `llm/stream` 调用返回句柄后,循环会发出一条实时 `agent/model-request` 通知,并收容该通知的失败;通知中包含轮次、步骤、路由,以及可选的、与注册项绑定的容量。这是 AgentLoop 观察到的一次尝试,并不能证明提供方 I/O 已开始:准备阶段或外层 waterfall(瀑布式事件)的同步失败不会发出通知,而短路句柄或之后的惰性适配器构造、失败或中止仍会计入。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。
|
||||
|
||||
插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败以及带内的终止错误或中止结束才进入 `agent/request-error`;中间件、结果处理、工具及其他扩展失败会直接关闭轮次。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实、不可变的先前失败、为请求提供服务的适配器注册所对应的不可变重试策略,以及轮次信号;如果没有最终适配器为其提供服务,则该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;循环用其错误关闭失败轮次,并在不插入空闲通知的情况下开启一个编号重试轮次。成功会清除连续失败历史;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。
|
||||
|
||||
|
||||
@@ -591,11 +591,16 @@ export class ReactLoopAgent implements Agent {
|
||||
&& persistedConfig.model === route.model
|
||||
? persistedConfig.reasoningEffort
|
||||
: undefined
|
||||
const maxTokens = this.options.maxTokens
|
||||
const seedConfig = deepFreeze(structuredClone(
|
||||
this.requestHeaderLogged
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- the instance logged the header it now folds
|
||||
? persistedConfig!
|
||||
: { ...route, ...reasoningEffort === undefined ? {} : { reasoningEffort } },
|
||||
: {
|
||||
...route,
|
||||
...reasoningEffort === undefined ? {} : { reasoningEffort },
|
||||
...maxTokens === undefined ? {} : { maxTokens },
|
||||
},
|
||||
))
|
||||
const proposedConfig = await this.loopCtx.waterfall(
|
||||
agentCarrier(this), 'agent/request', this, turn, step, signal,
|
||||
|
||||
@@ -112,6 +112,14 @@ function resolveMaxParallelToolCalls(value: number | undefined): number {
|
||||
return maxParallelToolCalls
|
||||
}
|
||||
|
||||
/** Reject an output-token cap that cannot be represented exactly on the request wire. */
|
||||
function assertAgentOptions(options: AgentOptions): void {
|
||||
if (options.maxTokens !== undefined
|
||||
&& (!Number.isSafeInteger(options.maxTokens) || options.maxTokens <= 0)) {
|
||||
throw new TypeError('agent maxTokens must be a positive safe integer')
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepared-but-unpublished agent resources sharing one memoized teardown. */
|
||||
interface PreparedAgent {
|
||||
agent: ReactLoopAgent
|
||||
@@ -196,6 +204,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
sessionId: z.string().min(1),
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER),
|
||||
cwd: z.string(),
|
||||
resumeSessionId: z.string(),
|
||||
})).default([]),
|
||||
@@ -327,6 +336,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* fuses caller cancellation with lifecycle teardown for setup awaits.
|
||||
*/
|
||||
private prepare(ownerCtx: Context, id: SessionId, options: AgentOptions, session: Session, callerSignal?: AbortSignal): PreparedAgent {
|
||||
assertAgentOptions(options)
|
||||
ownerCtx.fiber.assertActive()
|
||||
// Every caller reaches prepare() synchronously from a service method
|
||||
// whose Cordis dispatch already requires the live factory fiber, or
|
||||
|
||||
@@ -46,6 +46,19 @@ function send(agent: Agent, text: string) {
|
||||
}
|
||||
|
||||
describe('agent loop', () => {
|
||||
it.each([0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])(
|
||||
'rejects invalid AgentOptions.maxTokens %s before publication',
|
||||
async (maxTokens) => {
|
||||
const ctx = await harness(new MockAdapter([]))
|
||||
expect(() => ctx.agentLoop.create(
|
||||
SessionId('invalid-max-tokens'),
|
||||
{ provider: 'mock', model: 'mock', maxTokens },
|
||||
)).toThrow('agent maxTokens must be a positive safe integer')
|
||||
expect(ctx.agents.list()).toEqual([])
|
||||
expect(ctx.sessions.list()).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
it('runs a simple turn: queued message → model → idle, with ordered events', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hello there')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
@@ -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 packages/core/agent/README.md
|
||||
README.md: ad79e101974eb187df3093092a282fc6e5850857
|
||||
README.zh.md: 84849df23dfdc668f0d18c05ed6d9fd4d8c315d6
|
||||
README.md: 75e5f5d7bfa32d4d1f2e76c56ddd56ddd0c0c350
|
||||
README.zh.md: de341022868ce542a2534d0eaecea6d10bc43260
|
||||
|
||||
@@ -14,6 +14,8 @@ Tracks live agents and carries the initiating Agent through asynchronous driver
|
||||
|
||||
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
|
||||
|
||||
`AgentOptions` supplies the initial provider/model route and an optional positive `maxTokens` output cap. The concrete loop records the cap in the request header and applies it to each conversation-model request; callers that omit it leave provider defaults in control.
|
||||
|
||||
- `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber.
|
||||
- Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`.
|
||||
- `ctx.agents.get(id: SessionId): Agent | undefined`
|
||||
|
||||
@@ -14,6 +14,8 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事
|
||||
|
||||
带作用域的注册表层:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在释放时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。
|
||||
|
||||
`AgentOptions` 提供初始的提供方/模型路由,以及可选的正整数 `maxTokens` 输出上限。实体循环会把该上限记录到请求 header,并应用到每次对话模型请求;调用方省略时由提供方默认值控制。
|
||||
|
||||
- `ctx.agents.register(agent: Agent): () => void`:记录一个 **已经构造完成** 的 agent。随调用 fiber 释放。
|
||||
- 高级有序生命周期:`enter(agent, owner): () => void` 强制 `agent.id === agent.session.id`,执行权威 ID 冲突检查,并在不通知的情况下插入;`owner` 显式记录实时创建方 agent 关系(根 agent 为 `undefined`),与持久会话谱系无关。`announce(agent)` 恰好发出一次 `agent/created`。创建监听器同步请求的 detach 会延后到该次分发结束;每次 detach 都会检查捕获的条目对象,因此陈旧能力无法删除后续使用同一 ID 的替代项。异步工厂使用这一拆分;普通插件使用 `register()`。
|
||||
- `ctx.agents.get(id: SessionId): Agent | undefined`
|
||||
@@ -48,7 +50,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
|
||||
|
||||
生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器静默后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。
|
||||
|
||||
大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点;`agent/model-request` 是失败会被收容的通知,它会报告外层流句柄已返回的尝试所用路由,以及可选的、与注册项绑定的上下文容量。它既不是持久状态,也不能证明提供方 I/O 已开始。`agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
|
||||
大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点;`agent/model-request` 通知的失败会被收容,外层流调用为一次尝试返回句柄后,该通知会报告其路由以及可选的、与注册项绑定的上下文容量。它既不是持久状态,也不能证明提供方 I/O 已开始。`agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
|
||||
|
||||
`PromptDecision.additionalContexts` 是数组,因此每个上下文都保留自己的来源。获准的提示词内容与每个附加上下文都会在轮次运行前成为各自独立、面向模型的 `user/message` 事件。包装下游允许决策的监听器会保留其 `content` 与 `additionalContexts`,除非有意替换任一字段;返回的允许决策是权威来源。
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface AgentOptions {
|
||||
provider?: string
|
||||
/** Model id interpreted by the selected provider adapter. */
|
||||
model?: string
|
||||
/** Maximum output tokens for each conversation-model request. */
|
||||
maxTokens?: number
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user