@migor/agentia 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +1 -1
  2. package/dist/AGENTS.md +73 -7
  3. package/dist/core/message.d.ts +148 -0
  4. package/dist/core/message.js +25 -0
  5. package/dist/core/message.js.map +1 -0
  6. package/dist/core/tool.d.ts +10 -9
  7. package/dist/engine/errors.d.ts +12 -0
  8. package/dist/engine/errors.js +46 -13
  9. package/dist/engine/errors.js.map +1 -1
  10. package/dist/engine/loop.d.ts +17 -11
  11. package/dist/engine/loop.js +34 -433
  12. package/dist/engine/loop.js.map +1 -1
  13. package/dist/engine/replay.d.ts +18 -3
  14. package/dist/engine/replay.js +49 -8
  15. package/dist/engine/replay.js.map +1 -1
  16. package/dist/engine/retry.d.ts +5 -4
  17. package/dist/engine/retry.js.map +1 -1
  18. package/dist/engine/spec.d.ts +5 -5
  19. package/dist/engine/spec.js.map +1 -1
  20. package/dist/engine/trace-diff.d.ts +57 -0
  21. package/dist/engine/trace-diff.js +188 -0
  22. package/dist/engine/trace-diff.js.map +1 -0
  23. package/dist/engine/trimming.d.ts +7 -7
  24. package/dist/engine/trimming.js +6 -5
  25. package/dist/engine/trimming.js.map +1 -1
  26. package/dist/engine/turn.d.ts +157 -0
  27. package/dist/engine/turn.js +500 -0
  28. package/dist/engine/turn.js.map +1 -0
  29. package/dist/engine/types.d.ts +6 -8
  30. package/dist/engine/usage.d.ts +2 -2
  31. package/dist/engine/usage.js.map +1 -1
  32. package/dist/eval/defineEval.js.map +1 -1
  33. package/dist/eval/harvest.d.ts +2 -2
  34. package/dist/eval/harvest.js.map +1 -1
  35. package/dist/eval/scripted.js.map +1 -1
  36. package/dist/index.d.ts +6 -1
  37. package/dist/index.js +3 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/integrations/anthropic.d.ts +41 -37
  40. package/dist/integrations/anthropic.js +408 -17
  41. package/dist/integrations/anthropic.js.map +1 -1
  42. package/dist/integrations/openai.d.ts +1 -1
  43. package/dist/integrations/openai.js +3 -3
  44. package/dist/integrations/openai.js.map +1 -1
  45. package/dist/runtime/run.js.map +1 -1
  46. package/dist/runtime/session.d.ts +5 -5
  47. package/dist/runtime/session.js.map +1 -1
  48. package/dist/toolkit/module.d.ts +2 -2
  49. package/dist/toolkit/module.js +36 -4
  50. package/dist/toolkit/module.js.map +1 -1
  51. package/dist/toolkit/skill.d.ts +3 -3
  52. package/dist/toolkit/subagent.d.ts +2 -2
  53. package/dist/toolkit/subagent.js +1 -1
  54. package/dist/transport/async.d.ts +2 -2
  55. package/package.json +2 -4
@@ -0,0 +1,157 @@
1
+ /**
2
+ * 回合执行机:agentLoop「一回合执行步骤」的全部机制,自 loop.ts 拆出(纯重构,零语义变更)。
3
+ *
4
+ * 分工:loop.ts 留入口与编排骨架(runAgent / runAgentScoped / agentLoop 本体 +
5
+ * 缺省旋钮解析);本文件装回合内部 —— 回合上下文装配(buildLoopContext)、
6
+ * 回合入口检查(checkTurnEntry)、流式请求与重试(streamTurn)、回合记账
7
+ * (recordTurnUsage)、stop_reason 分流(resolveStopReason)、工具执行
8
+ * (executeTurnTools / executeOneTool)。
9
+ * 依赖方向单向:loop.ts → turn.ts(分层守卫禁环);两侧共用的类型与 helper
10
+ * (AgentLoopArgs / textOf / replaceMessages)在本文件做 module 级 export,供 loop.ts
11
+ * 与测试 import —— 按仓库约定不进 src/index.ts(纯内部实现细节)。
12
+ */
13
+ import type { Message, MessageParam, ToolParam, ToolResultBlockParam, ToolUseBlock } from '../core/message.js';
14
+ import type { AgentTool, JsonSchema, ModelClient, ModelPricing, RecorderBackend, SchemaType } from '../core/tool.js';
15
+ import type { SpanError, SpanId } from '../core/trace.js';
16
+ import type { BudgetGuard } from './budget.js';
17
+ import type { ResolvedRetry, RetryOptions } from './retry.js';
18
+ import type { AgentStopReason, ContextPolicy, SystemParam } from './types.js';
19
+ export interface AgentLoopArgs<S extends JsonSchema = JsonSchema> {
20
+ client: ModelClient;
21
+ model: string;
22
+ maxTokens: number;
23
+ maxIterations: number;
24
+ system?: SystemParam;
25
+ /** 本轮循环自有消息(内部复制,不改调用方数组) */
26
+ messages: MessageParam[];
27
+ tools: AgentTool[];
28
+ recorder: RecorderBackend;
29
+ /** llm.turn 的父 span(run 根 / 子 agent 的 capability span) */
30
+ parentSpanId: SpanId | null;
31
+ onText?: (delta: string) => void;
32
+ /** 中断信号:中止后不再发起新回合,以 stopReason='aborted' 收尾 */
33
+ signal?: AbortSignal;
34
+ /** 模型请求重试策略(缺省开启;false 关闭)。见 engine/retry.ts */
35
+ retry?: RetryOptions | false;
36
+ /** 上下文预算策略(compaction / context editing),每回合发送前调用 */
37
+ contextPolicy?: ContextPolicy;
38
+ /** 结构化结果 schema:存在时追加隐藏 submit_result 工具(见 RunAgentOptions.resultSchema) */
39
+ resultSchema?: S;
40
+ /**
41
+ * 模型往返计数的外部持有者。抛错路径(请求失败)也要能报出**已发生**的往返次数,
42
+ * 所以用对象就地累加,而不是只靠返回值。
43
+ */
44
+ progress?: {
45
+ iterations: number;
46
+ };
47
+ /** 成本硬管控(C1):整条 run 累计 token 上限;记账后判断,超限即停 */
48
+ maxTotalTokens?: number;
49
+ /** 成本硬管控(C1):累计成本(美元)上限;依赖价格表,见 createBudgetGuard */
50
+ maxCostUsd?: number;
51
+ /** 单个工具执行超时(毫秒);超时该条 tool_result 记 is_error,不杀 run */
52
+ toolTimeoutMs?: number;
53
+ /** 同回合并行工具上限;缺省 Infinity(= 全部并行) */
54
+ maxToolConcurrency?: number;
55
+ /** 事件正文截断上限;同 RunAgentOptions.maxEventChars(三类事件共用同一个值) */
56
+ maxEventChars?: number | false;
57
+ /** 价格表覆盖(F1):覆盖内置单价或给其他 provider 的模型定价 */
58
+ priceOverrides?: Record<string, ModelPricing>;
59
+ /** 未定价模型回调(F2):本循环作用域内每模型一次 */
60
+ onUnpricedModel?: (info: {
61
+ model: string;
62
+ spanId: string;
63
+ }) => void;
64
+ }
65
+ /**
66
+ * 回合上下文:一次 agentLoop 调用内、跨「一回合执行步骤」共享的全部状态。
67
+ * 可变字段(typed / submitted / progress / messages)由执行步骤就地更新。
68
+ */
69
+ export interface LoopContext<S extends JsonSchema = JsonSchema> {
70
+ args: AgentLoopArgs<S>;
71
+ /** 本轮循环自有消息(内部复制,不改调用方数组;各处持同一引用,见 replaceMessages) */
72
+ messages: MessageParam[];
73
+ /** 实际发给 API 的工具表:开发者工具 + resultSchema 模式追加的隐藏 submit_result */
74
+ apiTools: ToolParam[];
75
+ /** resultSchema 模式在末尾追加过提交指令的 system(见 appendResultInstruction) */
76
+ system?: SystemParam;
77
+ retryCfg: ResolvedRetry | null;
78
+ pricing: Record<string, ModelPricing>;
79
+ /** 未定价模型去重(F2):本循环作用域内每模型只回调一次 */
80
+ unpricedSeen: Set<string>;
81
+ budget: BudgetGuard | undefined;
82
+ progress: {
83
+ iterations: number;
84
+ };
85
+ /** submit_result 校验通过的结构化结果(先到先得,见 executeOneTool) */
86
+ typed: SchemaType<S> | undefined;
87
+ submitted: boolean;
88
+ }
89
+ /**
90
+ * 装配回合上下文:resultSchema 模式(隐藏提交工具 + system 指令)、重试配置、
91
+ * 价格表、预算护栏。全是循环开始前的**一次性**准备;
92
+ * 装配冲突(同名 submit_result)与非法单价在这里抛错。
93
+ */
94
+ export declare function buildLoopContext<S extends JsonSchema>(args: AgentLoopArgs<S>): LoopContext<S>;
95
+ /** 超限收尾的结构化 error:与 refusal / max_tokens 同口径(非正常收尾都带「为什么」) */
96
+ export declare function budgetError<S extends JsonSchema>(args: AgentLoopArgs<S>, over: 'tokens' | 'cost'): SpanError;
97
+ /** 中止收尾的结构化 error(回合入口检查与回合中途两处共用,文案一致) */
98
+ export declare function abortedError(): SpanError;
99
+ /** 回合入口的终止结论:已取消或预算已在回合间被推超(都带结构化 error) */
100
+ export interface EntryHalt {
101
+ stopReason: AgentStopReason;
102
+ error: SpanError;
103
+ }
104
+ /**
105
+ * 回合入口检查:已取消 / 预算已被上一回合的工具执行推超 → 给出终止结论;
106
+ * 否则给上下文策略一个机会(编辑/压缩预算超限的历史),返回 null 继续本回合。
107
+ */
108
+ export declare function checkTurnEntry<S extends JsonSchema>(ctx: LoopContext<S>, iteration: number): Promise<EntryHalt | null>;
109
+ /** 一回合的模型请求结果:turn span id + 成功时的 finalMessage;aborted 表示中途被取消 */
110
+ export interface TurnOutcome {
111
+ turnId: SpanId;
112
+ message?: Message;
113
+ aborted: boolean;
114
+ }
115
+ /**
116
+ * —— 一次逻辑回合:可能含多次尝试(重试);每次尝试开自己的 llm.turn span ——
117
+ * 可重试失败按 retryCfg 退避后重试(已吐出文本的尝试不重试,否则会重复输出);
118
+ * 不可重试的失败原样抛出,由外层(runAgent / 子 agent 运行器)标记根/capability 并收尾。
119
+ */
120
+ export declare function streamTurn<S extends JsonSchema>(ctx: LoopContext<S>): Promise<TurnOutcome>;
121
+ /**
122
+ * 回合记账:usage 换算 + 成本估算(未定价记事件 + 回调)+ 关 llm.turn span + token 属性。
123
+ * 预算护栏依赖「记账完成后」的 snapshot,所以本函数必须先于任何 budget.check 调用。
124
+ */
125
+ export declare function recordTurnUsage<S extends JsonSchema>(ctx: LoopContext<S>, turnId: SpanId, message: Message): void;
126
+ /** 回合收尾分流:finish = 终止/边界分支(带 stopReason 与收尾文本);tools = 还有工具要执行 */
127
+ export type StopResolution = {
128
+ kind: 'finish';
129
+ stopReason: AgentStopReason;
130
+ finalText: string;
131
+ error?: SpanError;
132
+ } | {
133
+ kind: 'tools';
134
+ toolUses: ToolUseBlock[];
135
+ };
136
+ /**
137
+ * —— 终止/边界分支(每个都给出终止结论,退出循环不再兜底改判)——
138
+ * 五种已知 stop_reason 各有归宿;剩余形态按「有没有可执行块」区分:
139
+ * 畸形 tool_use(块为空)与框架不认识的 stop_reason。
140
+ */
141
+ export declare function resolveStopReason(message: Message, maxTokens: number): StopResolution;
142
+ /**
143
+ * —— 执行工具:默认全并行,可由 maxToolConcurrency 收窄(C2);
144
+ * 单条 user 消息回全部 tool_result(抑制并行是反模式)——
145
+ * submit_result 校验通过会就地更新 ctx.typed / ctx.submitted(先到先得,见 executeOneTool)。
146
+ */
147
+ export declare function executeTurnTools<S extends JsonSchema>(ctx: LoopContext<S>, turnId: SpanId, toolUses: ToolUseBlock[], overBudget: 'tokens' | 'cost' | null): Promise<ToolResultBlockParam[]>;
148
+ export declare function textOf(message: Message): string;
149
+ /**
150
+ * 用 `next` 原地替换 `target` 的全部内容(保持数组引用不变 —— 循环各处持同一数组)。
151
+ *
152
+ * **不要**写回 `target.splice(0, target.length, ...next)`:展开传参受 V8 实参个数上限
153
+ * 约束,`next` 超过约 12 万项即抛 `RangeError: Maximum call stack size exceeded`
154
+ * (实测 12 万 ok、30 万抛)。`next` 来自调用方注入的 `contextPolicy`,长度不受框架
155
+ * 控制,所以用循环逐项写,彻底没有这个上限。
156
+ */
157
+ export declare function replaceMessages(target: MessageParam[], next: readonly MessageParam[]): void;
@@ -0,0 +1,500 @@
1
+ /**
2
+ * 回合执行机:agentLoop「一回合执行步骤」的全部机制,自 loop.ts 拆出(纯重构,零语义变更)。
3
+ *
4
+ * 分工:loop.ts 留入口与编排骨架(runAgent / runAgentScoped / agentLoop 本体 +
5
+ * 缺省旋钮解析);本文件装回合内部 —— 回合上下文装配(buildLoopContext)、
6
+ * 回合入口检查(checkTurnEntry)、流式请求与重试(streamTurn)、回合记账
7
+ * (recordTurnUsage)、stop_reason 分流(resolveStopReason)、工具执行
8
+ * (executeTurnTools / executeOneTool)。
9
+ * 依赖方向单向:loop.ts → turn.ts(分层守卫禁环);两侧共用的类型与 helper
10
+ * (AgentLoopArgs / textOf / replaceMessages)在本文件做 module 级 export,供 loop.ts
11
+ * 与测试 import —— 按仓库约定不进 src/index.ts(纯内部实现细节)。
12
+ */
13
+ import { validateJsonSchema } from '../core/schema.js';
14
+ import { stringifySafe, truncateWithMark } from '../core/json.js';
15
+ import { classifyError, isAbortError } from './errors.js';
16
+ import { createBudgetGuard } from './budget.js';
17
+ import { mapWithConcurrency, TIMED_OUT, withTimeout } from './concurrency.js';
18
+ import { backoffDelay, resolveRetry, sleep } from './retry.js';
19
+ import { buildPricing, costEstimate, usageFromAnthropic } from './usage.js';
20
+ /**
21
+ * 事件正文(tool.input / tool.output 的 body)缺省截断上限(字符)。
22
+ *
23
+ * 与「入参」/「成功出参」/「失败出参」三类一一对应:失败出参减半是为了让
24
+ * 「哪个工具老超时」这类判断在**一行**里看得完(正文本身多为一句错误摘要,
25
+ * 1000 已远超常见长度,只有工具把异常里的长上下文一起吐回来时才会触发)。
26
+ * `RunAgentOptions.maxEventChars` 一经给出,三类统一改用该值(见 `limit`)。
27
+ */
28
+ const DEFAULT_EVENT_CHARS = 2000;
29
+ const DEFAULT_ERROR_EVENT_CHARS = 1000;
30
+ /** 隐藏提交工具名:resultSchema 模式下由 engine 内部追加,不属开发者工具菜单 */
31
+ const SUBMIT_RESULT = 'submit_result';
32
+ /** resultSchema 模式下追加到 system 末尾的指令 */
33
+ const RESULT_INSTRUCTION = '本任务要求结构化结果:完成必要的信息收集与工具调用后,必须调用 submit_result 工具提交最终结果' +
34
+ '(input 严格符合该工具的 input_schema);不要仅以普通文本结束作答。';
35
+ /**
36
+ * 把结果提交指令追加到 system 末尾:string 时以空行拼接;SystemTextBlock[] 时
37
+ * 在数组末尾 push 一个无 cache_control 的 text block —— 不污染稳定前缀的缓存 breakpoint。
38
+ */
39
+ function appendResultInstruction(system) {
40
+ if (!system)
41
+ return RESULT_INSTRUCTION;
42
+ if (typeof system === 'string')
43
+ return `${system}\n\n${RESULT_INSTRUCTION}`;
44
+ return [...system, { type: 'text', text: RESULT_INSTRUCTION }];
45
+ }
46
+ /**
47
+ * 装配回合上下文:resultSchema 模式(隐藏提交工具 + system 指令)、重试配置、
48
+ * 价格表、预算护栏。全是循环开始前的**一次性**准备;
49
+ * 装配冲突(同名 submit_result)与非法单价在这里抛错。
50
+ */
51
+ export function buildLoopContext(args) {
52
+ // resultSchema 模式:追加隐藏 submit_result 工具 + system 末尾指令。
53
+ // 该工具由 engine 内部注入,不属开发者菜单;菜单已有同名工具视为装配冲突。
54
+ let apiTools = args.tools.map(toApiTool);
55
+ let system = args.system;
56
+ if (args.resultSchema) {
57
+ if (args.tools.some((t) => t.name === SUBMIT_RESULT)) {
58
+ throw new Error(`装配冲突:工具菜单已含 "${SUBMIT_RESULT}",与 resultSchema 的隐藏提交工具同名`);
59
+ }
60
+ apiTools = [
61
+ ...apiTools,
62
+ {
63
+ name: SUBMIT_RESULT,
64
+ description: '任务完成时调用它提交最终结构化结果(input 必须符合本工具的 input_schema)',
65
+ input_schema: args.resultSchema,
66
+ },
67
+ ];
68
+ system = appendResultInstruction(args.system);
69
+ }
70
+ const retryCfg = resolveRetry(args.retry);
71
+ // 价格表(F1):每次循环解析一次 —— 非法单价在 buildPricing 里立刻抛错(不静默算 NaN)
72
+ const pricing = buildPricing(args.priceOverrides);
73
+ // 成本硬管控(C1):从数值选项就地装配(把「超限」记进 run 根 span 的事件)。
74
+ // 只在记账完成后判断 —— 见 agentLoop 里 overBudget 的用法(何时「停」、何时只记事件)。
75
+ const budget = args.maxTotalTokens != null || args.maxCostUsd != null
76
+ ? createBudgetGuard({
77
+ maxTotalTokens: args.maxTotalTokens,
78
+ maxCostUsd: args.maxCostUsd,
79
+ onExceed: (snap) => {
80
+ // 观测是辅助动作:记事件失败不得把「超限」这件事变成崩溃
81
+ try {
82
+ if (args.parentSpanId)
83
+ args.recorder.event(args.parentSpanId, 'budget.exceeded', snap);
84
+ }
85
+ catch {
86
+ /* ignore */
87
+ }
88
+ },
89
+ })
90
+ : undefined;
91
+ return {
92
+ args,
93
+ messages: [...args.messages],
94
+ apiTools,
95
+ system,
96
+ retryCfg,
97
+ pricing,
98
+ unpricedSeen: new Set(),
99
+ budget,
100
+ progress: args.progress ?? { iterations: 0 }, // 就地累加,抛错时调用方仍读得到
101
+ typed: undefined,
102
+ submitted: false,
103
+ };
104
+ }
105
+ /** 超限收尾的结构化 error:与 refusal / max_tokens 同口径(非正常收尾都带「为什么」) */
106
+ export function budgetError(args, over) {
107
+ return {
108
+ type: 'budget_exceeded',
109
+ message: over === 'tokens'
110
+ ? `累计 token 已超过上限 ${args.maxTotalTokens}`
111
+ : `累计成本已超过上限 $${args.maxCostUsd}`,
112
+ retryable: false,
113
+ };
114
+ }
115
+ /** 中止收尾的结构化 error(回合入口检查与回合中途两处共用,文案一致) */
116
+ export function abortedError() {
117
+ return { type: 'aborted', message: 'run 已被取消', retryable: false };
118
+ }
119
+ /**
120
+ * 回合入口检查:已取消 / 预算已被上一回合的工具执行推超 → 给出终止结论;
121
+ * 否则给上下文策略一个机会(编辑/压缩预算超限的历史),返回 null 继续本回合。
122
+ */
123
+ export async function checkTurnEntry(ctx, iteration) {
124
+ const { args, messages } = ctx;
125
+ // 调用方已取消:不再发起新回合,直接以 aborted 收尾(不抛异常,语义确定)
126
+ if (args.signal?.aborted) {
127
+ return { stopReason: 'aborted', error: abortedError() };
128
+ }
129
+ // 成本硬管控(C1)回合入口再判一次:上一回合的**工具执行**(子 agent 循环等嵌套能力)
130
+ // 可能已把整条 run 的累计用量推过上限(回合末的那次判断当时还没超)—— 在发起新
131
+ // 请求前拦住:不再发请求 = 不再花钱。与「模型自然收尾不改判」不冲突:自然收尾在
132
+ // 上一回合就 break 了,走不到这里。
133
+ if (ctx.budget) {
134
+ const over = ctx.budget.check(args.recorder.snapshot('ok'));
135
+ if (over) {
136
+ return { stopReason: 'budget_exceeded', error: budgetError(args, over) };
137
+ }
138
+ }
139
+ // 发送前给上下文策略一个机会(编辑/压缩预算超限的历史)
140
+ if (args.contextPolicy) {
141
+ const next = await args.contextPolicy.beforeTurn(messages, {
142
+ iteration,
143
+ model: args.model,
144
+ });
145
+ if (next && next !== messages) {
146
+ if (args.parentSpanId) {
147
+ args.recorder.event(args.parentSpanId, 'context.budget', {
148
+ from: messages.length,
149
+ to: next.length,
150
+ model: args.model,
151
+ });
152
+ }
153
+ replaceMessages(messages, next);
154
+ }
155
+ }
156
+ return null;
157
+ }
158
+ /**
159
+ * —— 一次逻辑回合:可能含多次尝试(重试);每次尝试开自己的 llm.turn span ——
160
+ * 可重试失败按 retryCfg 退避后重试(已吐出文本的尝试不重试,否则会重复输出);
161
+ * 不可重试的失败原样抛出,由外层(runAgent / 子 agent 运行器)标记根/capability 并收尾。
162
+ */
163
+ export async function streamTurn(ctx) {
164
+ const { args } = ctx;
165
+ const signal = args.signal;
166
+ const retryCfg = ctx.retryCfg;
167
+ let turnId = '';
168
+ let message;
169
+ let aborted = false;
170
+ let emitted = false; // 本回合是否已吐出过文本(吐过就不能重试,否则会重复输出)
171
+ for (let attempt = 1;; attempt++) {
172
+ turnId = args.recorder.begin('llm.turn', args.model, args.parentSpanId);
173
+ if (attempt > 1)
174
+ args.recorder.setAttribute(turnId, 'retry.attempt', attempt);
175
+ try {
176
+ const stream = args.client.messages.stream({
177
+ model: args.model,
178
+ max_tokens: args.maxTokens,
179
+ ...(ctx.system ? { system: ctx.system } : {}),
180
+ ...(ctx.apiTools.length ? { tools: ctx.apiTools } : {}),
181
+ messages: ctx.messages,
182
+ ...(signal ? { signal } : {}),
183
+ });
184
+ stream.on('text', (delta) => {
185
+ emitted = true;
186
+ try {
187
+ args.onText?.(delta);
188
+ }
189
+ catch {
190
+ /* 观测是辅助动作:回调抛错不得影响 run(与 onUnpricedModel 同口径) */
191
+ }
192
+ });
193
+ message = await stream.finalMessage();
194
+ break;
195
+ }
196
+ catch (e) {
197
+ const errInfo = classifyError(e);
198
+ args.recorder.end(turnId, { status: 'error', error: errInfo });
199
+ // 中断:不冒泡、不重试 —— 以确定语义收尾
200
+ if (isAbortError(e) || signal?.aborted) {
201
+ aborted = true;
202
+ break;
203
+ }
204
+ // 可重试:配置允许 + 次数未尽 + 判定可重试 + 本次尝试未产出任何文本
205
+ const canRetry = retryCfg !== null && attempt < retryCfg.maxAttempts && retryCfg.isRetryable(e) && !emitted;
206
+ if (!canRetry)
207
+ throw e; // 冒泡:runAgent 或子 agent 运行器负责标记根/capability 与收尾
208
+ const delayMs = backoffDelay(attempt, retryCfg);
209
+ args.recorder.event(turnId, 'llm.retry', { attempt, delayMs, error: errInfo.type });
210
+ try {
211
+ retryCfg.onRetry({ attempt, delayMs, error: errInfo });
212
+ }
213
+ catch {
214
+ /* 观测是辅助动作:回调抛错不得影响 run(与 onUnpricedModel 同口径) */
215
+ }
216
+ try {
217
+ await sleep(delayMs, signal);
218
+ }
219
+ catch {
220
+ aborted = true; // 退避期间被取消
221
+ break;
222
+ }
223
+ }
224
+ }
225
+ return { turnId, message, aborted };
226
+ }
227
+ /**
228
+ * 回合记账:usage 换算 + 成本估算(未定价记事件 + 回调)+ 关 llm.turn span + token 属性。
229
+ * 预算护栏依赖「记账完成后」的 snapshot,所以本函数必须先于任何 budget.check 调用。
230
+ */
231
+ export function recordTurnUsage(ctx, turnId, message) {
232
+ const { args } = ctx;
233
+ const usage = message.usage ? usageFromAnthropic(message.usage) : undefined;
234
+ if (usage) {
235
+ const cost = costEstimate(args.model, usage, ctx.pricing);
236
+ usage.costEstimate = cost;
237
+ // 未定价(F2):模型不在价格表内 → 显式记事件 + 回调,别让 maxCostUsd 静默失效
238
+ if (cost === undefined) {
239
+ args.recorder.event(turnId, 'usage.unpriced', { model: args.model });
240
+ if (!ctx.unpricedSeen.has(args.model)) {
241
+ ctx.unpricedSeen.add(args.model);
242
+ try {
243
+ args.onUnpricedModel?.({ model: args.model, spanId: turnId });
244
+ }
245
+ catch {
246
+ /* 观测是辅助动作:回调抛错不得影响 run */
247
+ }
248
+ }
249
+ }
250
+ }
251
+ args.recorder.end(turnId, { usage });
252
+ args.recorder.setAttribute(turnId, 'input_tokens', usage?.inputTokens ?? 0);
253
+ args.recorder.setAttribute(turnId, 'output_tokens', usage?.outputTokens ?? 0);
254
+ args.recorder.setAttribute(turnId, 'cache_read_tokens', usage?.cacheReadTokens ?? 0);
255
+ args.recorder.setAttribute(turnId, 'cache_creation_tokens', usage?.cacheCreationTokens ?? 0);
256
+ }
257
+ /**
258
+ * —— 终止/边界分支(每个都给出终止结论,退出循环不再兜底改判)——
259
+ * 五种已知 stop_reason 各有归宿;剩余形态按「有没有可执行块」区分:
260
+ * 畸形 tool_use(块为空)与框架不认识的 stop_reason。
261
+ */
262
+ export function resolveStopReason(message, maxTokens) {
263
+ if (message.stop_reason === 'end_turn') {
264
+ return { kind: 'finish', stopReason: 'end_turn', finalText: textOf(message) };
265
+ }
266
+ if (message.stop_reason === 'refusal') {
267
+ return {
268
+ kind: 'finish',
269
+ stopReason: 'refusal',
270
+ finalText: textOf(message),
271
+ error: { type: 'refusal', message: 'model refused the request', retryable: false },
272
+ };
273
+ }
274
+ if (message.stop_reason === 'max_tokens') {
275
+ // 与 budget_exceeded / refusal 同口径:非正常收尾都带结构化 error(进 run 根 span),
276
+ // 否则 trace 里这类 run「失败却没有原因」
277
+ return {
278
+ kind: 'finish',
279
+ stopReason: 'max_tokens',
280
+ finalText: textOf(message),
281
+ error: {
282
+ type: 'max_tokens',
283
+ message: `模型输出触顶被截断(max_tokens=${maxTokens})`,
284
+ retryable: false,
285
+ },
286
+ };
287
+ }
288
+ if (message.stop_reason === 'pause_turn') {
289
+ // 无 server tools 时正常不会到;避免无限循环直接停
290
+ return {
291
+ kind: 'finish',
292
+ stopReason: 'pause_turn',
293
+ finalText: textOf(message),
294
+ error: {
295
+ type: 'pause_turn',
296
+ message: '模型返回 pause_turn(无 server tools 的场景不应出现),防死循环直接收尾',
297
+ retryable: false,
298
+ },
299
+ };
300
+ }
301
+ if (message.stop_reason === 'stop_sequence') {
302
+ // 命中 stop 序列 = 正常收尾(与 end_turn 同类),不是失败
303
+ return { kind: 'finish', stopReason: 'stop_sequence', finalText: textOf(message) };
304
+ }
305
+ const toolUses = message.content.filter((b) => b.type === 'tool_use');
306
+ if (toolUses.length === 0) {
307
+ // 到这里的剩余 stop_reason 不会产生可执行块,防死循环直接停:
308
+ // 'tool_use' 但块为空(畸形响应)与「本框架不认识的 stop_reason」区分开,
309
+ // 后者保留已产出的文本并挂一条可诊断的 error(run 仍按失败收尾)。
310
+ const stopReason = message.stop_reason === 'tool_use' ? 'tool_use_no_blocks' : 'unknown_stop_reason';
311
+ return {
312
+ kind: 'finish',
313
+ stopReason,
314
+ finalText: textOf(message),
315
+ ...(stopReason === 'unknown_stop_reason'
316
+ ? {
317
+ error: {
318
+ type: 'agent_error',
319
+ message: `模型返回了未识别的 stop_reason: ${String(message.stop_reason)}`,
320
+ retryable: false,
321
+ },
322
+ }
323
+ : {}),
324
+ };
325
+ }
326
+ return { kind: 'tools', toolUses };
327
+ }
328
+ /**
329
+ * —— 执行工具:默认全并行,可由 maxToolConcurrency 收窄(C2);
330
+ * 单条 user 消息回全部 tool_result(抑制并行是反模式)——
331
+ * submit_result 校验通过会就地更新 ctx.typed / ctx.submitted(先到先得,见 executeOneTool)。
332
+ */
333
+ export async function executeTurnTools(ctx, turnId, toolUses, overBudget) {
334
+ const { args } = ctx;
335
+ // 成本硬管控(C1):走得到这里说明循环还要继续(模型要求调工具)—— 超限就停,
336
+ // 连带不执行这批工具(避免超预算的 run 继续产生副作用)。已产出的文本保留。
337
+ // 例外:submit_result 是纯内部的结构化提交(零副作用、不触外部系统),超预算也照常
338
+ // 处理本回合的它 —— 模型已经把最终结果交出来了,连同回合一起丢弃等于白烧这一回合
339
+ // (与「自然收尾不因超预算改判失败」同口径)。
340
+ const runnable = overBudget
341
+ ? toolUses.filter((u) => args.resultSchema !== undefined && u.name === SUBMIT_RESULT)
342
+ : toolUses;
343
+ return mapWithConcurrency(runnable, args.maxToolConcurrency ?? Number.POSITIVE_INFINITY, (use) => executeOneTool(ctx, turnId, use));
344
+ }
345
+ /**
346
+ * 执行单个工具调用(含隐藏 submit_result 分支),产出该条的 tool_result 块。
347
+ * 工具的 tool.input / tool.output 事件都记在本回合的 turn span 上(tool_use_id 配对)。
348
+ */
349
+ async function executeOneTool(ctx, turnId, use) {
350
+ const { args } = ctx;
351
+ const tool = args.tools.find((t) => t.name === use.name);
352
+ // 工具级时序(E1):起点在事件之前 —— durationMs 覆盖「入参校验 + 执行 + 超时等待」
353
+ // 的完整处理时长,是「哪一步慢」的可信基线。并行工具各记各的(tool_use_id 配对)。
354
+ const toolStartedAt = Date.now();
355
+ // tool_use_id 一并记账:同名工具并行时,重放只有靠 id 才能把入参出参正确配对
356
+ args.recorder.event(turnId, 'tool.input', {
357
+ tool: use.name,
358
+ tool_use_id: use.id,
359
+ input: limit(use.input, args.maxEventChars ?? DEFAULT_EVENT_CHARS),
360
+ });
361
+ const toolCtx = {
362
+ client: args.client,
363
+ recorder: args.recorder,
364
+ parentSpanId: turnId,
365
+ ...(args.signal ? { signal: args.signal } : {}),
366
+ // 价格覆盖透传给嵌套能力(F1):否则子 agent 用同一模型会退化成"未定价"
367
+ ...(args.priceOverrides ? { priceOverrides: args.priceOverrides } : {}),
368
+ // 事件截断口径同样透传:调试期开了全文,子 agent 的工具事件不该还是被截断的
369
+ ...(args.maxEventChars != null ? { maxEventChars: args.maxEventChars } : {}),
370
+ // 成本护栏(C1)同样透传:预算是整条 run(含各级子 agent)的口径,
371
+ // 子循环拿不到就等于护栏在子循环期间离线(各级共享同一 recorder,按同一账单判断)
372
+ ...(args.maxTotalTokens != null ? { maxTotalTokens: args.maxTotalTokens } : {}),
373
+ ...(args.maxCostUsd != null ? { maxCostUsd: args.maxCostUsd } : {}),
374
+ };
375
+ let ok = true;
376
+ let content = '';
377
+ // 失败归类(E1):只记「为什么没成」,不记栈 —— 观测看得清「哪个工具老超时」
378
+ let errorKind;
379
+ if (args.resultSchema && use.name === SUBMIT_RESULT) {
380
+ // 隐藏提交工具:校验通过即携结果收尾(循环在 agentLoop 下方 break);
381
+ // 校验失败回 is_error(含路径,模型可自我修正),同回合其他工具照常执行。
382
+ // 校验本身也在 try 内:畸形 resultSchema($ref 成环等)只该废掉这一次提交,
383
+ // 不该让整次 run 以 error 收场(否则 trace 把该回合记成 ok,与 run 结论矛盾)。
384
+ try {
385
+ const invalid = validateJsonSchema(args.resultSchema, use.input);
386
+ if (invalid) {
387
+ ok = false;
388
+ errorKind = 'invalid_input';
389
+ content = `invalid input: ${invalid}`;
390
+ }
391
+ else {
392
+ content = 'submitted';
393
+ // 同回合并行多个 submit_result:**先到先得**(首个校验通过的生效,后续忽略)——
394
+ // 不 guarded 赋值的话 typed 由并发完成顺序竞态决定,同输入可能产出不同结果
395
+ if (!ctx.submitted) {
396
+ // 模型提交的 input 已过 resultSchema 校验 → 断言为 SchemaType<S>(信任边界在此)
397
+ ctx.typed = use.input;
398
+ ctx.submitted = true;
399
+ }
400
+ }
401
+ }
402
+ catch (e) {
403
+ ok = false;
404
+ errorKind = 'threw';
405
+ const err = classifyError(e);
406
+ content = `error(${err.type}): ${err.message}`;
407
+ }
408
+ }
409
+ else if (!tool) {
410
+ ok = false;
411
+ errorKind = 'unknown_tool';
412
+ content = `unknown tool: ${use.name}`;
413
+ }
414
+ else {
415
+ // 模型给的 input 先过 schema 校验:不合法直接回 is_error(含路径,
416
+ // 模型可自我修正),不进方法体 —— schema 是方法与模型间的运行时契约。
417
+ // 校验本身也在 try 内:畸形 schema($ref 成环等)只该废掉这一个调用,
418
+ // 不该让整次 run 以 error 收场。
419
+ try {
420
+ const invalid = validateJsonSchema(tool.inputSchema, use.input);
421
+ if (invalid) {
422
+ ok = false;
423
+ errorKind = 'invalid_input';
424
+ content = `invalid input: ${invalid}`;
425
+ }
426
+ else {
427
+ // 工具级超时(C2):超时 = **放弃等待**(AgentTool.run 没有 signal 参数,
428
+ // 工具内部可能还在跑、副作用可能已发生),该条 tool_result 记 is_error 回模型
429
+ // —— 与「工具抛错不中断 run」同语义,模型可自行换路。
430
+ const out = await withTimeout(Promise.resolve(tool.run(use.input, toolCtx)), args.toolTimeoutMs ?? 0);
431
+ if (out === TIMED_OUT) {
432
+ ok = false;
433
+ errorKind = 'timeout';
434
+ content = `error(timeout): 工具执行超过 ${args.toolTimeoutMs}ms`;
435
+ }
436
+ else {
437
+ content = out;
438
+ }
439
+ }
440
+ }
441
+ catch (e) {
442
+ ok = false;
443
+ errorKind = 'threw';
444
+ const err = classifyError(e);
445
+ content = `error(${err.type}): ${err.message}`;
446
+ }
447
+ }
448
+ args.recorder.event(turnId, 'tool.output', {
449
+ tool: use.name,
450
+ tool_use_id: use.id,
451
+ ok,
452
+ // 耗时(毫秒):成功/失败/超时/入参被拒四条路径都记(E1)
453
+ durationMs: Math.max(0, Date.now() - toolStartedAt),
454
+ ...(errorKind ? { errorKind } : {}),
455
+ content: limit(content, args.maxEventChars ?? (ok ? DEFAULT_EVENT_CHARS : DEFAULT_ERROR_EVENT_CHARS)),
456
+ });
457
+ return {
458
+ type: 'tool_result',
459
+ tool_use_id: use.id,
460
+ content: stringifySafe(content),
461
+ is_error: !ok,
462
+ };
463
+ }
464
+ function toApiTool(t) {
465
+ return {
466
+ name: t.name,
467
+ description: t.description,
468
+ input_schema: t.inputSchema,
469
+ ...(t.strict ? { strict: true } : {}),
470
+ };
471
+ }
472
+ export function textOf(message) {
473
+ return message.content
474
+ .filter((b) => b.type === 'text')
475
+ .map((b) => b.text)
476
+ .join('\n');
477
+ }
478
+ /**
479
+ * 用 `next` 原地替换 `target` 的全部内容(保持数组引用不变 —— 循环各处持同一数组)。
480
+ *
481
+ * **不要**写回 `target.splice(0, target.length, ...next)`:展开传参受 V8 实参个数上限
482
+ * 约束,`next` 超过约 12 万项即抛 `RangeError: Maximum call stack size exceeded`
483
+ * (实测 12 万 ok、30 万抛)。`next` 来自调用方注入的 `contextPolicy`,长度不受框架
484
+ * 控制,所以用循环逐项写,彻底没有这个上限。
485
+ */
486
+ export function replaceMessages(target, next) {
487
+ target.length = 0;
488
+ for (const m of next)
489
+ target.push(m);
490
+ }
491
+ /**
492
+ * 截断到上限字符,超长加省略标记(格式由 core/json.ts 的 truncateWithMark 单一提供)。
493
+ * `n === false` 表示**不截断**(`RunAgentOptions.maxEventChars: false`)——
494
+ * 传数字时三类事件共用同一个上限,缺省值由调用点给出。
495
+ */
496
+ function limit(x, n) {
497
+ const s = stringifySafe(x);
498
+ return n === false ? s : truncateWithMark(s, n);
499
+ }
500
+ //# sourceMappingURL=turn.js.map