@armadra/agent 0.7.5 → 0.7.6

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 (62) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/CHANGELOG.zh-CN.md +21 -0
  3. package/dist/agent/session-classifier.d.ts +2 -0
  4. package/dist/agent/session-classifier.js +24 -3
  5. package/dist/agent/session-extensions.d.ts +1 -0
  6. package/dist/agent/session-extensions.js +3 -0
  7. package/dist/agent/session-subagent.js +9 -2
  8. package/dist/agent/subagent-background.d.ts +5 -1
  9. package/dist/agent/subagent-background.js +13 -1
  10. package/dist/agent/subagent-fork.d.ts +10 -5
  11. package/dist/agent/subagent-fork.js +22 -6
  12. package/dist/agent/subagent-registry.d.ts +1 -1
  13. package/dist/agent/subagent-registry.js +1 -1
  14. package/dist/agents/catalog.d.ts +1 -0
  15. package/dist/ai/apis/max-tokens.d.ts +4 -0
  16. package/dist/ai/apis/max-tokens.js +12 -1
  17. package/dist/ai/providers/enrich.d.ts +6 -2
  18. package/dist/ai/providers/enrich.js +13 -2
  19. package/dist/ai/providers/max-tokens-cache.d.ts +22 -0
  20. package/dist/ai/providers/max-tokens-cache.js +95 -0
  21. package/dist/ai/providers/registry.js +3 -3
  22. package/dist/bundle/ama.cjs +296 -103
  23. package/dist/checkpoints/shadow-git.d.ts +5 -1
  24. package/dist/checkpoints/shadow-git.js +11 -2
  25. package/dist/cli/compose-providers.d.ts +1 -0
  26. package/dist/cli/compose-providers.js +3 -0
  27. package/dist/cli/compose-store.d.ts +6 -2
  28. package/dist/cli/compose-store.js +8 -4
  29. package/dist/cli/compose.js +2 -2
  30. package/dist/config/json-schema.js +1 -0
  31. package/dist/config/key-docs.js +1 -0
  32. package/dist/config/schema-w5.js +2 -0
  33. package/dist/config/settings-registry.js +1 -0
  34. package/dist/config/types-w5.d.ts +5 -0
  35. package/dist/i18n/catalog.d.ts +2 -0
  36. package/dist/i18n/messages/config-keys.d.ts +2 -0
  37. package/dist/i18n/messages/config-keys.js +2 -0
  38. package/dist/i18n/messages/config.d.ts +2 -0
  39. package/dist/i18n/messages/interactive.d.ts +2 -0
  40. package/dist/i18n/messages/interactive.js +2 -0
  41. package/dist/i18n/messages/session.js +2 -2
  42. package/dist/modes/interactive/agent-ui.js +7 -0
  43. package/dist/session/manager.d.ts +3 -1
  44. package/dist/session/manager.js +5 -0
  45. package/dist/session/offload.d.ts +10 -2
  46. package/dist/session/offload.js +27 -4
  47. package/dist/tools/background-jobs.d.ts +4 -4
  48. package/dist/tools/background-jobs.js +4 -4
  49. package/dist/tools/bash.d.ts +2 -1
  50. package/dist/tools/bash.js +4 -3
  51. package/dist/tools/task-ctl.js +2 -1
  52. package/dist/tools/task.d.ts +9 -0
  53. package/dist/tools/task.js +27 -1
  54. package/docs/agents.md +14 -3
  55. package/docs/en/permissions.md +1 -1
  56. package/docs/en/providers.md +4 -4
  57. package/docs/en/sessions.md +2 -1
  58. package/docs/permissions.md +1 -1
  59. package/docs/providers.md +9 -5
  60. package/docs/session-format.md +2 -1
  61. package/docs/sessions.md +2 -1
  62. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ English · [简体中文](CHANGELOG.zh-CN.md)
5
5
  > This file is in English starting with 0.6.0. Release notes for 0.1 through 0.5.1 are in Chinese in
6
6
  > [CHANGELOG.zh-CN.md](CHANGELOG.zh-CN.md). New entries go into both files.
7
7
 
8
+ ## 0.7.6 (2026-10-10)
9
+
10
+ ### Model efficiency
11
+
12
+ - **The auto-mode classifier picks a small model on relays and custom providers too**: when the session model inherits an official catalog entry by id (e.g. `packy/deepseek-v4-pro`), the classifier uses the same vendor's catalog `small` model if the provider's model table lists it (`packy/deepseek-v4-flash`); only listed models are used, never synthesized ones. `permission.autoModel` still overrides (#153).
13
+ - **Fork sub-agents are steered back to their task when `task` / `task_ctl` is rejected**: the rejection now says the caller is a forked sub-agent that cannot start sub-agents, asks it to do the sub-task itself, and quotes the first line of its `<task>` instructions (fresh sub-agents keep the old text; the tool list and the first request are unchanged). DeepSeek retest: one run made no `task` call, the other made one and then read the file and reported (#191).
14
+ - **Learned `max_tokens` limits persist across processes**: when an endpoint rejects with a 400 stating its output limit, the limit is now also written to `<dataDir>/models/max-tokens-caps.json` and loaded back on startup, so later processes no longer get the first request rejected once per model (packy `kimi-k2.5`: second process 0 rejections, previously 1). Entries expire after 30 days; delete the file to re-probe.
15
+ - **Warning for a wrong `models[].catalog` reference**: `catalog: "provider/id"` that matches no catalog entry now produces a registry warning (startup header count, `ama models list`, `AMA_LOG`) instead of silently inheriting nothing.
16
+
17
+ ### Sessions
18
+
19
+ - **Unreadable session images are reported**: when a downgraded image cannot be read back because the session file was changed by another process, the warning now reaches the session log (`ama: [warn] cannot read session entry …` on stderr, filtered by `AMA_LOG`; a notice in the TUI) instead of being dropped. Warnings raised while the file is being opened are buffered and shown once the session is ready (at most 16, the rest counted); the source session of `--fork` reports through the same log.
20
+ - **`subagents.forkMaxContextRatio`**: the share of (window − `compaction.reserveTokens`) above which a fork sub-agent falls back to fresh is now configurable (0.05–0.95, default 0.5, user level only). Built-in types, `general` included, stay fresh by default; docs/agents.md explains the per-turn cost of re-reading the parent context.
21
+ - **Fork sub-agents are told `task` / `task_ctl` are unavailable**: the `<task>` message now lists them and asks not to call them (they stay in the tool list and are still rejected by depth). In two DeepSeek retests the sub-agent still made one rejected `task` call each time.
22
+ - **Background fork tasks report their mode right away**: the `running` result of a background `task` now carries `details.context` (`fork` / `fresh`) once the mode is known; a queued task or one waiting for its worktree still gets it later in `TaskInfo.context`.
23
+ - **Background `bash` job output fits the session result limit in one cut**: `{job, action: "wait" | "output" | "stop"}` now tail-truncates to the same byte limit as foreground `bash` (50 KB or `maxToolResultChars` − 512, whichever is smaller) instead of a fixed 2000 lines / 50 KB that the session then cut again in the middle; the note still gives the shown line count and `Full output: <path>`.
24
+
25
+ ### Checkpoints
26
+
27
+ - **A slow first shadow snapshot no longer downgrades the session**: the first snapshot (which builds the index) is no longer timed, and the session downgrades to `tools` only after two snapshots in a row exceed 3 seconds (one slow snapshot followed by a normal one keeps shadow-git). The one-time notice now says so (#194).
28
+
8
29
  ## 0.7.5 (2026-10-10)
9
30
 
10
31
  ### Memory footprint
@@ -4,6 +4,27 @@
4
4
 
5
5
  > 从 0.6.0 起 [CHANGELOG.md](CHANGELOG.md) 为英文,本文件保留中文记录(0.1–0.5.1 的完整历史在此)。新条目两份都要加。
6
6
 
7
+ ## 0.7.6(2026-10-10)
8
+
9
+ ### 模型调用效率
10
+
11
+ - **中转 / 自定义供应商的 auto 分类器也用小模型**:会话模型按 id 继承了官方目录条目时(如 `packy/deepseek-v4-pro`),若该供应商模型表里列有同厂商目录的 `small` 模型(`packy/deepseek-v4-flash`)就用它分类;只认模型表里列出的条目,不用合成模型。`permission.autoModel` 仍优先(#153)。
12
+ - **fork 子 Agent 调 `task` / `task_ctl` 被拒后回到子任务**:拒绝文案说明它是 fork 出的子 Agent、不能再派生,要求自己完成子任务并附 `<task>` 指令首行(fresh 子 Agent 文案不变;工具表与首请求不变)。DeepSeek 复测:一次没调 `task`,另一次调了一次被拒后读文件并给出报告(#191)。
13
+ - **学到的 `max_tokens` 上限跨进程保留**:端点以 400 说出输出上限时,上限同时写入 `<dataDir>/models/max-tokens-caps.json`,启动时载回,之后的进程不再每个模型首个请求被拒一次(packy `kimi-k2.5`:第二个进程 0 次 400,原为 1 次)。条目 30 天过期,删掉该文件即重测。
14
+ - **`models[].catalog` 写错时给 warning**:`catalog: "provider/id"` 不命中任何目录条目时,注册表记一条 warning(启动头部计数、`ama models list`、`AMA_LOG`),不再静默不继承。
15
+
16
+ ### 会话
17
+
18
+ - **会话图片读不回时有提示**:降级图片因会话文件被其它进程改写而读不回时,告警进会话日志(stderr 一行 `ama: [warn] cannot read session entry …`,受 `AMA_LOG` 过滤;TUI 显示在通知区),不再被丢弃。打开文件期间的告警先缓冲、会话建好时补出(最多 16 条,其余计数);`--fork` 的源会话走同一日志。
19
+ - **`subagents.forkMaxContextRatio`**:fork 子 Agent 回落为 fresh 的比例(父上一次请求占(窗口 − `compaction.reserveTokens`)的份额)可配置,0.05–0.95,缺省 0.5,只认用户级。内置类型(包括 `general`)仍缺省 fresh;docs/agents.md 说明了每回合重读父上下文的成本。
20
+ - **fork 子 Agent 被告知 `task` / `task_ctl` 不可用**:`<task>` 消息列出这两个工具并要求不要调用(它们仍在工具表里、按深度拒绝)。两次 DeepSeek 复测里子 Agent 仍各调了一次被拒的 `task`。
21
+ - **后台 fork 任务立即给出模式**:后台 `task` 的 `running` 结果在模式已知时带 `details.context`(`fork` / `fresh`);排队或等待 worktree 的任务仍在之后的 `TaskInfo.context` 里给出。
22
+ - **后台 `bash` 任务输出按会话结果上限一次截到位**:`{job, action: "wait" | "output" | "stop"}` 改为按与前台 `bash` 相同的字节上限(50 KB 与 `maxToolResultChars` − 512 取小)尾截断,不再固定 2000 行 / 50 KB 再被会话层截掉中段;说明行照旧写显示行数与 `Full output: <路径>`。
23
+
24
+ ### 检查点
25
+
26
+ - **首次影子快照偏慢不再让会话降级**:首次快照(建索引)不计时,之后连续两次快照超过 3 秒才降级为 `tools`(单次偏慢、下一次正常则继续用影子 git);降级仍只提示一次,文案改为说明是连续两次(#194)。
27
+
7
28
  ## 0.7.5(2026-10-10)
8
29
 
9
30
  ### 内存占用
@@ -4,6 +4,8 @@
4
4
  * - 模型:`options.permissionClassifier.model`(config `permission.autoModel`)解析成功就用它;
5
5
  * 否则(未配置、找不到;找不到时记一次 warning)[ME-D] 用会话供应商目录里的小模型(目录文件级
6
6
  * `small`,D12;要能找到且有 key),再否则用当前会话模型;选择结果记一次 debug。
7
+ * - [#153] 中转 / 自定义供应商(没有目录文件):会话模型按 id 继承的目录条目 → 原厂 `small` →
8
+ * 本供应商模型表(config `models[]`、发现缓存)里**已列出**的同一条目;不走合成路径。
7
9
  * - 请求是**独立**的:只有分类系统提示与一条用户消息,不带会话转录与工具表;`purpose: "classify"`
8
10
  * 让会话层缓存包装直接透传(不观测、不暂停 / 触发保温、不成为下一次请求的前缀依据);
9
11
  * `cacheRetention: "none"`,关闭思考,`maxTokens` 256。
@@ -4,13 +4,15 @@
4
4
  * - 模型:`options.permissionClassifier.model`(config `permission.autoModel`)解析成功就用它;
5
5
  * 否则(未配置、找不到;找不到时记一次 warning)[ME-D] 用会话供应商目录里的小模型(目录文件级
6
6
  * `small`,D12;要能找到且有 key),再否则用当前会话模型;选择结果记一次 debug。
7
+ * - [#153] 中转 / 自定义供应商(没有目录文件):会话模型按 id 继承的目录条目 → 原厂 `small` →
8
+ * 本供应商模型表(config `models[]`、发现缓存)里**已列出**的同一条目;不走合成路径。
7
9
  * - 请求是**独立**的:只有分类系统提示与一条用户消息,不带会话转录与工具表;`purpose: "classify"`
8
10
  * 让会话层缓存包装直接透传(不观测、不暂停 / 触发保温、不成为下一次请求的前缀依据);
9
11
  * `cacheRetention: "none"`,关闭思考,`maxTokens` 256。
10
12
  * - 用量记一条 `usage{kind:"permission_classify"}` 条目:计入 `/session` 费用,不进上下文。
11
13
  */
12
14
  import { CLASSIFIER_MAX_TOKENS, PermissionClassifier, } from "../permissions/classifier.js";
13
- import { catalogSmall } from "../ai/providers/catalog.js";
15
+ import { catalogByAlias, catalogSmall } from "../ai/providers/catalog.js";
14
16
  export const CLASSIFY_USAGE_KIND = "permission_classify";
15
17
  function textOf(message) {
16
18
  return message.content
@@ -19,9 +21,28 @@ function textOf(message) {
19
21
  .join("")
20
22
  .trim();
21
23
  }
22
- /** 会话供应商的目录小模型:能找到且 key 可用(或无需 key)才用。 */
24
+ /** [#153] 会话模型继承的目录条目所属厂商的 `small`,在本供应商模型表里列出的条目 id。 */
25
+ function inferredSmall(core, session) {
26
+ const hit = catalogByAlias(session.id);
27
+ if (hit === undefined)
28
+ return undefined;
29
+ const origin = hit.ref.slice(0, hit.ref.indexOf("/"));
30
+ const small = catalogSmall(origin);
31
+ const target = `${origin}/${small}`;
32
+ if (small === undefined || hit.ref === target)
33
+ return undefined;
34
+ const listed = core.options.providers.get(session.provider)?.models ?? [];
35
+ return listed.find((m) => {
36
+ if (m.id === small)
37
+ return true;
38
+ const entry = catalogByAlias(m.id);
39
+ return entry?.ref === target && entry.tier === undefined;
40
+ })?.id;
41
+ }
42
+ /** 分类用的小模型:会话供应商的目录 small,或 [#153] 推断出的同厂商小模型;能找到且 key 可用(或无需 key)才用。 */
23
43
  async function smallModel(core, session) {
24
- const small = catalogSmall(session.provider);
44
+ const own = catalogSmall(session.provider);
45
+ const small = own === undefined ? inferredSmall(core, session) : own;
25
46
  if (small === undefined || small === session.id)
26
47
  return undefined;
27
48
  const lookup = core.options.providers.findModel(`${session.provider}/${small}`);
@@ -20,6 +20,7 @@
20
20
  * 工厂在会话构造中途调用:只可读 `core.options / depth / cwd / manager`,不要 emit 或请求模型。
21
21
  * - 钩子抛错不影响会话:同步钩子的异常记 warn 日志后忽略;`beforePrompts` / `onAgentSettled`
22
22
  * 的异常同样记日志、按「无追加」处理。
23
+ * - 会话级诊断(#183):构造时顺带把 `core.manager` 的告警接到 `core.log`(每个会话实例一次)。
23
24
  * - `onAgentSettled` 不要等待人工输入(计划审批等):需要等待的流程自行异步进行,之后用
24
25
  * `prompt / followUp` 开新回合。
25
26
  */
@@ -20,6 +20,7 @@
20
20
  * 工厂在会话构造中途调用:只可读 `core.options / depth / cwd / manager`,不要 emit 或请求模型。
21
21
  * - 钩子抛错不影响会话:同步钩子的异常记 warn 日志后忽略;`beforePrompts` / `onAgentSettled`
22
22
  * 的异常同样记日志、按「无追加」处理。
23
+ * - 会话级诊断(#183):构造时顺带把 `core.manager` 的告警接到 `core.log`(每个会话实例一次)。
23
24
  * - `onAgentSettled` 不要等待人工输入(计划审批等):需要等待的流程自行异步进行,之后用
24
25
  * `prompt / followUp` 开新回合。
25
26
  */
@@ -36,6 +37,8 @@ export class SessionExtensions {
36
37
  list.push(extension);
37
38
  }
38
39
  this.list = list;
40
+ // 会话级诊断(#183):manager 的告警(图片读不回)走会话日志;动态读 options.log,TUI 换掉也生效
41
+ core.manager.setWarn((message) => core.log("warn", message));
39
42
  }
40
43
  get size() {
41
44
  return this.list.length;
@@ -137,7 +137,9 @@ async function startAmaChild(parent, spec, createChild, run) {
137
137
  }
138
138
  const thinkingLevel = spec.request.thinkingLevel ?? spec.agent.thinking ?? base.thinkingLevel ?? "off";
139
139
  const wanted = spec.resumeFile === undefined && requestedContext(spec.request, spec.agent) === "fork";
140
- const plan = wanted ? forkPlan(parent, spec, model, thinkingLevel) : undefined;
140
+ // #149:比例来自 `subagents.forkMaxContextRatio`(注册表环境里的整段配置)
141
+ const ratio = subagentRegistryFor(parent).env.modelConfig?.subagents?.forkMaxContextRatio;
142
+ const plan = wanted ? forkPlan(parent, spec, model, thinkingLevel, ratio) : undefined;
141
143
  if (plan !== undefined && "fallback" in plan)
142
144
  parent.log("info", `task ${spec.taskId}: fork falls back to fresh (${plan.fallback})`);
143
145
  const forked = plan !== undefined && "manager" in plan ? plan.manager : undefined;
@@ -149,6 +151,11 @@ async function startAmaChild(parent, spec, createChild, run) {
149
151
  // fork:工具表 = 父活动集(前缀不变),类型限制的工具在执行层拒绝
150
152
  const inherited = base.activeTools ?? (parent.options.tools ?? []).map((tool) => tool.name);
151
153
  const unavailable = inherited.filter((name) => !allowed.includes(name) && !PARENT_ONLY_TOOLS.includes(name));
154
+ // #150:<task> 里也列出按深度拒绝的 task / task_ctl(执行层的拒绝文案不变)
155
+ const briefUnavailable = [
156
+ ...unavailable,
157
+ ...PARENT_ONLY_TOOLS.filter((name) => inherited.includes(name)),
158
+ ];
152
159
  const options = {
153
160
  ...parent.options,
154
161
  ...base,
@@ -252,7 +259,7 @@ async function startAmaChild(parent, spec, createChild, run) {
252
259
  : forkBrief({
253
260
  prompt: run.prompt,
254
261
  role: spec.agent.prompt,
255
- unavailable,
262
+ unavailable: briefUnavailable,
256
263
  ...(spec.isolated ? { worktree: { cwd: spec.cwd, parentCwd: parent.cwd } } : {}),
257
264
  });
258
265
  let current = runOnce(first, spec.origin);
@@ -18,7 +18,11 @@ export type BackgroundReason = "user" | "timeout" | "host";
18
18
  export type BackgroundSetting = "auto" | "always" | "never";
19
19
  /** `auto`:交互 / RPC / ACP 缺省后台,`-p`(无人值守)缺省前台。 */
20
20
  export declare function resolveTaskBackground(setting: BackgroundSetting | undefined, unattended: boolean): boolean;
21
- export declare function startedResult(record: TaskRecord, outputFile?: string): SubagentResult;
21
+ /**
22
+ * 直接后台的工具结果。`settle`(任务的运行 promise)给出时先等一个宏任务:ama 子会话在第一个
23
+ * await 之前就发出 `context` 事件,池有空位时此刻模式已知;排队或 worktree 隔离未就绪时不带。
24
+ */
25
+ export declare function startedResult(record: TaskRecord, outputFile?: string, settle?: Promise<unknown>): Promise<SubagentResult>;
22
26
  export declare function backgroundedResult(record: TaskRecord, reason: BackgroundReason, afterMs: number, outputFile?: string): SubagentResult;
23
27
  /** 前台任务的等待者:`resolve` 让工具调用先返回,`detachParent` 解绑父 signal 与进度回调。 */
24
28
  export declare function foregroundWaiter(detachParent: () => void): {
@@ -32,9 +32,21 @@ function runningResult(record, text, outputFile) {
32
32
  };
33
33
  if (outputFile !== undefined)
34
34
  result.outputFile = outputFile;
35
+ // #156:fork / fresh 已定时带上实际模式(与完成结果的 details.context 同源)
36
+ if (record.info.context !== undefined)
37
+ result.context = record.info.context;
35
38
  return result;
36
39
  }
37
- export function startedResult(record, outputFile) {
40
+ /**
41
+ * 直接后台的工具结果。`settle`(任务的运行 promise)给出时先等一个宏任务:ama 子会话在第一个
42
+ * await 之前就发出 `context` 事件,池有空位时此刻模式已知;排队或 worktree 隔离未就绪时不带。
43
+ */
44
+ export async function startedResult(record, outputFile, settle) {
45
+ if (settle !== undefined)
46
+ await Promise.race([
47
+ settle.catch(() => undefined),
48
+ new Promise((resolve) => setImmediate(resolve)),
49
+ ]);
38
50
  return runningResult(record, startedText(record.info.taskId, record.agent.name), outputFile);
39
51
  }
40
52
  export function backgroundedResult(record, reason, afterMs, outputFile) {
@@ -5,7 +5,7 @@
5
5
  * 同一条 assistant 里并行的多个 fork 任务共享同一个 fork 点。
6
6
  * - `forkPlan`:满足条件时用 `SessionManager.fork(entryId, { head })` 复制分支,`head` 是
7
7
  * `custom{ama.task, context:"fork", forkedFrom}`(子会话首条仍是 `ama.task`);否则给出回落原因:
8
- * 与父不同的模型 / 思考级别、父还没有真实请求、父上一次请求超过 `FORK_MAX_CONTEXT_RATIO` ×
8
+ * 与父不同的模型 / 思考级别、父还没有真实请求、父上一次请求超过 `subagents.forkMaxContextRatio`(缺省 0.5)×
9
9
  * (窗口 − reserveTokens)、找不到发起调用的 assistant。续聊与 resume 不经过这里(照原文件重开)。
10
10
  * - `forkBrief`:角色说明 + 任务合成一条 user 消息(固定英文),追加在继承的历史之后;子会话不写
11
11
  * `role` 节,工具表与父逐字节相同,类型限制的工具在执行层拒绝(`unavailableTools`)。
@@ -16,8 +16,13 @@ import type { SessionManager } from "../session/manager.js";
16
16
  import type { SessionEntry } from "../session/types.js";
17
17
  import type { SubagentRequest } from "../tools/types.js";
18
18
  import type { AgentSessionOptions } from "./session-core.js";
19
- /** 父上一次请求的输入 token 超过(子模型窗口 − reserveTokens)的这个比例时回落 fresh。 */
19
+ /**
20
+ * 父上一次请求的输入 token 超过(子模型窗口 − reserveTokens)的这个比例时回落 fresh;
21
+ * 缺省值,可由 `subagents.forkMaxContextRatio` 覆盖(#149)。
22
+ */
20
23
  export declare const FORK_MAX_CONTEXT_RATIO = 0.5;
24
+ /** 配置的比例:非有限数或不在 (0, 1) 内时用缺省。 */
25
+ export declare function forkRatio(ratio: number | undefined): number;
21
26
  export type ContextMode = "fork" | "fresh";
22
27
  /** 请求的上下文模式:调用参数 > 类型 frontmatter > `fresh`。 */
23
28
  export declare function requestedContext(request: Pick<SubagentRequest, "context">, agent: Pick<AgentDefinition, "context">): ContextMode;
@@ -36,7 +41,7 @@ export interface ForkParent {
36
41
  export interface ForkSpec {
37
42
  taskId: string;
38
43
  agent: Pick<AgentDefinition, "name">;
39
- request: Pick<SubagentRequest, "parentToolCallId" | "description">;
44
+ request: Pick<SubagentRequest, "parentToolCallId" | "description" | "prompt">;
40
45
  /** 子会话 cwd(隔离时是 worktree)。 */
41
46
  cwd: string;
42
47
  }
@@ -48,9 +53,9 @@ export type ForkPlan = {
48
53
  };
49
54
  /**
50
55
  * D3 的回落条件依次检查,全部通过才复制分支(复制即落盘:父有文件时子会话文件立即写出)。
51
- * `model` / `thinking` 是子会话按类型与参数解析出的值。
56
+ * `model` / `thinking` 是子会话按类型与参数解析出的值;`ratio` 是 `subagents.forkMaxContextRatio`。
52
57
  */
53
- export declare function forkPlan(parent: ForkParent, spec: ForkSpec, model: Model, thinking: ModelThinkingLevel): ForkPlan;
58
+ export declare function forkPlan(parent: ForkParent, spec: ForkSpec, model: Model, thinking: ModelThinkingLevel, ratio?: number): ForkPlan;
54
59
  export interface ForkBriefInput {
55
60
  prompt: string;
56
61
  /** 类型正文(不含通用前言:这里的首句已说明子 Agent 身份)。 */
@@ -5,14 +5,24 @@
5
5
  * 同一条 assistant 里并行的多个 fork 任务共享同一个 fork 点。
6
6
  * - `forkPlan`:满足条件时用 `SessionManager.fork(entryId, { head })` 复制分支,`head` 是
7
7
  * `custom{ama.task, context:"fork", forkedFrom}`(子会话首条仍是 `ama.task`);否则给出回落原因:
8
- * 与父不同的模型 / 思考级别、父还没有真实请求、父上一次请求超过 `FORK_MAX_CONTEXT_RATIO` ×
8
+ * 与父不同的模型 / 思考级别、父还没有真实请求、父上一次请求超过 `subagents.forkMaxContextRatio`(缺省 0.5)×
9
9
  * (窗口 − reserveTokens)、找不到发起调用的 assistant。续聊与 resume 不经过这里(照原文件重开)。
10
10
  * - `forkBrief`:角色说明 + 任务合成一条 user 消息(固定英文),追加在继承的历史之后;子会话不写
11
11
  * `role` 节,工具表与父逐字节相同,类型限制的工具在执行层拒绝(`unavailableTools`)。
12
12
  */
13
+ import { FORK_CUSTOM_TYPE, taskLine } from "../tools/task.js";
13
14
  import { DEFAULT_COMPACTION_SETTINGS } from "./session-compaction.js";
14
- /** 父上一次请求的输入 token 超过(子模型窗口 − reserveTokens)的这个比例时回落 fresh。 */
15
+ /**
16
+ * 父上一次请求的输入 token 超过(子模型窗口 − reserveTokens)的这个比例时回落 fresh;
17
+ * 缺省值,可由 `subagents.forkMaxContextRatio` 覆盖(#149)。
18
+ */
15
19
  export const FORK_MAX_CONTEXT_RATIO = 0.5;
20
+ /** 配置的比例:非有限数或不在 (0, 1) 内时用缺省。 */
21
+ export function forkRatio(ratio) {
22
+ return ratio !== undefined && Number.isFinite(ratio) && ratio > 0 && ratio < 1
23
+ ? ratio
24
+ : FORK_MAX_CONTEXT_RATIO;
25
+ }
16
26
  const TASK_CUSTOM_TYPE = "ama.task";
17
27
  /** 请求的上下文模式:调用参数 > 类型 frontmatter > `fresh`。 */
18
28
  export function requestedContext(request, agent) {
@@ -27,9 +37,9 @@ export function forkPoint(branch, parentToolCallId) {
27
37
  }
28
38
  /**
29
39
  * D3 的回落条件依次检查,全部通过才复制分支(复制即落盘:父有文件时子会话文件立即写出)。
30
- * `model` / `thinking` 是子会话按类型与参数解析出的值。
40
+ * `model` / `thinking` 是子会话按类型与参数解析出的值;`ratio` 是 `subagents.forkMaxContextRatio`。
31
41
  */
32
- export function forkPlan(parent, spec, model, thinking) {
42
+ export function forkPlan(parent, spec, model, thinking, ratio) {
33
43
  const base = parent.childBase();
34
44
  if (model.provider !== base.model.provider || model.id !== base.model.id)
35
45
  return { fallback: `model ${model.provider}/${model.id} differs from the parent` };
@@ -40,7 +50,7 @@ export function forkPlan(parent, spec, model, thinking) {
40
50
  return { fallback: "the parent has not sent a request yet" };
41
51
  const reserve = parent.options.compaction?.reserveTokens ?? DEFAULT_COMPACTION_SETTINGS.reserveTokens;
42
52
  const window = model.contextWindow;
43
- const limit = window === undefined ? Infinity : FORK_MAX_CONTEXT_RATIO * Math.max(0, window - reserve);
53
+ const limit = window === undefined ? Infinity : forkRatio(ratio) * Math.max(0, window - reserve);
44
54
  if (last.promptTokens > limit)
45
55
  return { fallback: `parent context ${last.promptTokens} tokens exceeds ${Math.floor(limit)}` };
46
56
  const forkedFrom = forkPoint(parent.manager.branch(), spec.request.parentToolCallId);
@@ -63,6 +73,12 @@ export function forkPlan(parent, spec, model, thinking) {
63
73
  },
64
74
  },
65
75
  });
76
+ // #191:fork 标记(不进上下文);复制来的父 `ama.task` 快照可能更晚,故不复用那个类型
77
+ manager.append({
78
+ type: "custom",
79
+ customType: FORK_CUSTOM_TYPE,
80
+ data: { taskLine: taskLine(spec.request.prompt) },
81
+ });
66
82
  return { manager, forkedFrom };
67
83
  }
68
84
  /** fork 子会话的首条 user 消息(固定英文,发给模型)。 */
@@ -72,7 +88,7 @@ export function forkBrief(input) {
72
88
  "You are a sub-agent forked from the conversation above at this point. The main agent cannot " +
73
89
  "see your work, only your final reply; do not delegate further. Requests above were for the " +
74
90
  "main agent: do only this task. Instructions in this block take precedence over earlier " +
75
- "plans or reminders above.",
91
+ "plans or reminders above. Do not call task or task_ctl.",
76
92
  ];
77
93
  if (input.worktree !== undefined)
78
94
  lines.push(`Working directory for this task: ${input.worktree.cwd}. Relative paths in the ` +
@@ -62,7 +62,7 @@ export declare function registryOf(sessionId: string): SubagentRegistry | undefi
62
62
  export declare function subagentRegistryFor(host: RegistryHost, env?: SubagentEnvironment): SubagentRegistry;
63
63
  export declare class SubagentRegistry implements TaskControl {
64
64
  private readonly host;
65
- private readonly env;
65
+ readonly env: SubagentEnvironment;
66
66
  readonly catalog: AgentCatalog;
67
67
  private readonly pool;
68
68
  private readonly tasks;
@@ -245,7 +245,7 @@ export class SubagentRegistry {
245
245
  const file = this.outputFileFor(record.info.taskId);
246
246
  if (background) {
247
247
  void running.then((result) => this.notifier.notify(record, result));
248
- return Promise.resolve(startedResult(record, file));
248
+ return startedResult(record, file, running);
249
249
  }
250
250
  // [W7-B1] 前台:转后台(background())时等待者先行 resolve,工具调用立即返回
251
251
  const { waiter, promise } = foregroundWaiter(() => {
@@ -14,6 +14,7 @@ export interface AgentModelConfig {
14
14
  agents?: AgentsConfig;
15
15
  subagents?: {
16
16
  defaultModel?: string;
17
+ forkMaxContextRatio?: number;
17
18
  };
18
19
  models?: ModelsConfig;
19
20
  }
@@ -8,6 +8,8 @@
8
8
  * 以上限重发一次;之后同一模型的请求直接用上限。Anthropic「输入 + max_tokens 超出上下文」只按这次
9
9
  * 的输入算出可用值重发、不记上限;可用值不足 `MIN_OUTPUT_TOKENS` 判为溢出(overflow.ts 认得该文案)。
10
10
  * 重发只发生在 `start` 之前,流契约不变(与 `postWithCacheFallback` 同模式,包在它外层)。
11
+ * - 跨进程:学到的上限经 `onMaxTokensCap` 写进 `<dataDir>/models/max-tokens-caps.json`(30 天),
12
+ * 组装注册表时载回(providers/max-tokens-cache.ts,#152)。
11
13
  */
12
14
  import { type PostOptions } from "../http.js";
13
15
  import type { Model } from "../types.js";
@@ -19,6 +21,8 @@ export declare const OUTPUT_HEADROOM_TOKENS = 2048;
19
21
  export type MaxTokensField = "max_tokens" | "max_completion_tokens" | "max_output_tokens";
20
22
  /** `${provider}/${model}` → 已知上限(被动修正时记入,之后的请求直接用)。 */
21
23
  export declare const maxTokensCaps: Map<string, number>;
24
+ /** 被动修正学到新上限时通知(数据目录持久化用,max-tokens-cache.ts);返回取消函数。 */
25
+ export declare function onMaxTokensCap(listener: (key: string, cap: number) => void): () => void;
22
26
  type Json = Record<string, unknown>;
23
27
  /** 主动收紧后的 max_tokens;`window` 未知或 `fixed`(预算型思考)时返回 requested。 */
24
28
  export declare function clampMaxTokens(requested: number, window: number | undefined, estimatedInput: number, fixed: boolean): number;
@@ -8,6 +8,8 @@
8
8
  * 以上限重发一次;之后同一模型的请求直接用上限。Anthropic「输入 + max_tokens 超出上下文」只按这次
9
9
  * 的输入算出可用值重发、不记上限;可用值不足 `MIN_OUTPUT_TOKENS` 判为溢出(overflow.ts 认得该文案)。
10
10
  * 重发只发生在 `start` 之前,流契约不变(与 `postWithCacheFallback` 同模式,包在它外层)。
11
+ * - 跨进程:学到的上限经 `onMaxTokensCap` 写进 `<dataDir>/models/max-tokens-caps.json`(30 天),
12
+ * 组装注册表时载回(providers/max-tokens-cache.ts,#152)。
11
13
  */
12
14
  import { HttpError, errorText } from "../http.js";
13
15
  import { postWithCacheFallback } from "./cache-params.js";
@@ -17,6 +19,12 @@ export const MIN_OUTPUT_TOKENS = 1024;
17
19
  export const OUTPUT_HEADROOM_TOKENS = 2048;
18
20
  /** `${provider}/${model}` → 已知上限(被动修正时记入,之后的请求直接用)。 */
19
21
  export const maxTokensCaps = new Map();
22
+ const capListeners = new Set();
23
+ /** 被动修正学到新上限时通知(数据目录持久化用,max-tokens-cache.ts);返回取消函数。 */
24
+ export function onMaxTokensCap(listener) {
25
+ capListeners.add(listener);
26
+ return () => capListeners.delete(listener);
27
+ }
20
28
  /** 主动收紧后的 max_tokens;`window` 未知或 `fixed`(预算型思考)时返回 requested。 */
21
29
  export function clampMaxTokens(requested, window, estimatedInput, fixed) {
22
30
  if (fixed || window === undefined || !Number.isFinite(window) || window <= 0)
@@ -111,8 +119,11 @@ export async function postWithMaxTokensFallback(model, url, options, field) {
111
119
  throw error;
112
120
  if (rejection.cap >= sent || !budgetFits(body, rejection.cap))
113
121
  throw error;
114
- if (!rejection.transient)
122
+ if (!rejection.transient) {
115
123
  maxTokensCaps.set(key, rejection.cap);
124
+ for (const listener of capListeners)
125
+ listener(key, rejection.cap);
126
+ }
116
127
  return postWithCacheFallback(model, url, {
117
128
  ...options,
118
129
  body: withCap(body, field, rejection.cap),
@@ -25,6 +25,8 @@ export interface ModelMetadata {
25
25
  toolCall?: boolean;
26
26
  /** [ME-D] 按 id 继承的内置目录条目 `provider/id`。 */
27
27
  catalog?: string;
28
+ /** 显式 `catalog` / `modelsDev` 引用不命中时的告警(注册表转成 warnings,#154)。 */
29
+ warnings?: string[];
28
30
  }
29
31
  export type ModelsDevSource = ModelsDevIndex | (() => ModelsDevIndex | undefined) | undefined;
30
32
  /** 惰性取索引:只有真的要补字段时才读缓存(启动时没有自定义模型就不读约 2 MB 的缓存)。 */
@@ -32,9 +34,11 @@ export declare function lazyIndex(source: ModelsDevSource): () => ModelsDevIndex
32
34
  type EnrichedEntry = Omit<ModelConfig, "modelsDev" | "channels" | "catalog">;
33
35
  /**
34
36
  * 补全一个自定义模型条目(config 的 `models[]` 或合成的模型)。返回补过字段的条目(不含
35
- * `modelsDev` / `channels` / `catalog` 这些配置专用键)与每个字段的来源。
37
+ * `modelsDev` / `channels` / `catalog` 这些配置专用键)与每个字段的来源。显式 `catalog: "provider/id"`
38
+ * 或 `modelsDev` 引用不命中时记入 `metadata.warnings`(`providerId` 用于告警文案);写错的 `catalog`
39
+ * 不回落按 id 继承——用户显式指定,按指定失败处理。
36
40
  */
37
- export declare function enrichEntry(entry: ModelConfig, index: () => ModelsDevIndex | undefined): {
41
+ export declare function enrichEntry(entry: ModelConfig, index: () => ModelsDevIndex | undefined, providerId?: string): {
38
42
  entry: EnrichedEntry;
39
43
  metadata: ModelMetadata;
40
44
  };
@@ -79,9 +79,11 @@ function fillLimits(out, hit, sources) {
79
79
  }
80
80
  /**
81
81
  * 补全一个自定义模型条目(config 的 `models[]` 或合成的模型)。返回补过字段的条目(不含
82
- * `modelsDev` / `channels` / `catalog` 这些配置专用键)与每个字段的来源。
82
+ * `modelsDev` / `channels` / `catalog` 这些配置专用键)与每个字段的来源。显式 `catalog: "provider/id"`
83
+ * 或 `modelsDev` 引用不命中时记入 `metadata.warnings`(`providerId` 用于告警文案);写错的 `catalog`
84
+ * 不回落按 id 继承——用户显式指定,按指定失败处理。
83
85
  */
84
- export function enrichEntry(entry, index) {
86
+ export function enrichEntry(entry, index, providerId) {
85
87
  const { modelsDev, channels: _channels, catalog: _catalog, ...rest } = entry;
86
88
  const sources = {};
87
89
  for (const field of ENRICHABLE_FIELDS) {
@@ -89,11 +91,16 @@ export function enrichEntry(entry, index) {
89
91
  }
90
92
  const metadata = { sources, looked: false };
91
93
  const out = { ...rest };
94
+ const warnings = [];
95
+ const ref = `${providerId ?? "?"}/${entry.id}`;
92
96
  const hit = catalogHit(entry);
93
97
  if (hit !== undefined) {
94
98
  metadata.catalog = hit.ref;
95
99
  inheritIntrinsic(out, hit, sources);
96
100
  }
101
+ else if (typeof entry.catalog === "string") {
102
+ warnings.push(`catalog "${entry.catalog}" for "${ref}" not found; ignored`);
103
+ }
97
104
  const explicit = typeof modelsDev === "string" ? modelsDev : hit?.snapshotRef;
98
105
  const lookup = modelsDev !== false && (needsLookup(out) || modelsDev !== undefined);
99
106
  const idx = lookup ? index() : undefined;
@@ -127,8 +134,12 @@ export function enrichEntry(entry, index) {
127
134
  }
128
135
  }
129
136
  }
137
+ if (typeof modelsDev === "string" && metadata.looked && metadata.match === undefined)
138
+ warnings.push(`modelsDev "${modelsDev}" for "${ref}" not found`);
130
139
  if (hit !== undefined && modelsDev !== false)
131
140
  fillLimits(out, hit, sources);
141
+ if (warnings.length > 0)
142
+ metadata.warnings = warnings;
132
143
  return { entry: out, metadata };
133
144
  }
134
145
  /**
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `max_tokens` 上限的跨进程缓存:`<dataDir>/models/max-tokens-caps.json`(#152)。
3
+ *
4
+ * 被动修正(apis/max-tokens.ts)学到的 `provider/model` 上限原本只在进程内,重启后每个模型的首个请求仍被
5
+ * 400 拒一次。这里把它写进数据目录:组装注册表时载回进程内的表(进程内已有的键优先),学到新上限就把文件里
6
+ * 未过期的条目加上这一条原子写回(临时文件 + 改名;并发进程后写者赢,丢的一条下次再学)。条目 30 天过期,
7
+ * 过期后重新探测(中转放宽上限时不会一直压低输出);删掉文件即重测。只存模型引用、上限与时间,没有凭据。
8
+ */
9
+ export declare const MAX_TOKENS_CACHE_VERSION = 1;
10
+ /** 条目有效期:30 天。 */
11
+ export declare const MAX_TOKENS_CACHE_TTL_MS: number;
12
+ /** 缓存文件位置。 */
13
+ export declare function maxTokensCachePath(dataDir: string): string;
14
+ /** 载入未过期的上限到进程内的表(已有的键不覆盖);返回载入条数。 */
15
+ export declare function loadMaxTokensCaps(dataDir: string, now?: () => number): number;
16
+ /** 把一条新上限并入文件(顺带清掉过期条目);写失败不抛(缓存只是省一次 400)。 */
17
+ export declare function recordMaxTokensCap(dataDir: string, key: string, cap: number, now?: () => number): void;
18
+ /**
19
+ * 载入并订阅:之后学到的上限写回该数据目录。按 `dataDir` 幂等(同一进程重复组装注册表不重复订阅);
20
+ * 返回取消函数(取消后可重新 attach)。
21
+ */
22
+ export declare function attachMaxTokensCache(dataDir: string, now?: () => number): () => void;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `max_tokens` 上限的跨进程缓存:`<dataDir>/models/max-tokens-caps.json`(#152)。
3
+ *
4
+ * 被动修正(apis/max-tokens.ts)学到的 `provider/model` 上限原本只在进程内,重启后每个模型的首个请求仍被
5
+ * 400 拒一次。这里把它写进数据目录:组装注册表时载回进程内的表(进程内已有的键优先),学到新上限就把文件里
6
+ * 未过期的条目加上这一条原子写回(临时文件 + 改名;并发进程后写者赢,丢的一条下次再学)。条目 30 天过期,
7
+ * 过期后重新探测(中转放宽上限时不会一直压低输出);删掉文件即重测。只存模型引用、上限与时间,没有凭据。
8
+ */
9
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { maxTokensCaps, onMaxTokensCap } from "../apis/max-tokens.js";
12
+ export const MAX_TOKENS_CACHE_VERSION = 1;
13
+ /** 条目有效期:30 天。 */
14
+ export const MAX_TOKENS_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
15
+ /** 缓存文件位置。 */
16
+ export function maxTokensCachePath(dataDir) {
17
+ return join(dataDir, "models", "max-tokens-caps.json");
18
+ }
19
+ /** 读文件里未过期的条目;不存在、坏 JSON、版本不符或形状不对的条目一律忽略。 */
20
+ function readEntries(dataDir, now) {
21
+ const out = new Map();
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(readFileSync(maxTokensCachePath(dataDir), "utf8"));
25
+ }
26
+ catch {
27
+ return out;
28
+ }
29
+ if (typeof parsed !== "object" || parsed === null)
30
+ return out;
31
+ const file = parsed;
32
+ if (file.version !== MAX_TOKENS_CACHE_VERSION)
33
+ return out;
34
+ if (typeof file.caps !== "object" || file.caps === null)
35
+ return out;
36
+ for (const [key, value] of Object.entries(file.caps)) {
37
+ if (typeof value !== "object" || value === null)
38
+ continue;
39
+ const { cap, learnedAt } = value;
40
+ if (typeof cap !== "number" || !Number.isInteger(cap) || cap < 1)
41
+ continue;
42
+ if (typeof learnedAt !== "number" || !Number.isFinite(learnedAt))
43
+ continue;
44
+ if (now - learnedAt > MAX_TOKENS_CACHE_TTL_MS)
45
+ continue;
46
+ out.set(key, { cap, learnedAt });
47
+ }
48
+ return out;
49
+ }
50
+ /** 载入未过期的上限到进程内的表(已有的键不覆盖);返回载入条数。 */
51
+ export function loadMaxTokensCaps(dataDir, now = Date.now) {
52
+ let loaded = 0;
53
+ for (const [key, entry] of readEntries(dataDir, now())) {
54
+ if (maxTokensCaps.has(key))
55
+ continue;
56
+ maxTokensCaps.set(key, entry.cap);
57
+ loaded++;
58
+ }
59
+ return loaded;
60
+ }
61
+ /** 把一条新上限并入文件(顺带清掉过期条目);写失败不抛(缓存只是省一次 400)。 */
62
+ export function recordMaxTokensCap(dataDir, key, cap, now = Date.now) {
63
+ const at = now();
64
+ const entries = readEntries(dataDir, at);
65
+ entries.set(key, { cap, learnedAt: at });
66
+ const path = maxTokensCachePath(dataDir);
67
+ const tmp = `${path}.${process.pid}.tmp`;
68
+ try {
69
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
70
+ const caps = Object.fromEntries([...entries].sort(([a], [b]) => (a < b ? -1 : 1)));
71
+ writeFileSync(tmp, `${JSON.stringify({ version: MAX_TOKENS_CACHE_VERSION, caps }, null, 2)}\n`);
72
+ renameSync(tmp, path);
73
+ }
74
+ catch {
75
+ // 只读数据目录等:本进程内的表仍有效
76
+ }
77
+ }
78
+ const attached = new Map();
79
+ /**
80
+ * 载入并订阅:之后学到的上限写回该数据目录。按 `dataDir` 幂等(同一进程重复组装注册表不重复订阅);
81
+ * 返回取消函数(取消后可重新 attach)。
82
+ */
83
+ export function attachMaxTokensCache(dataDir, now = Date.now) {
84
+ const existing = attached.get(dataDir);
85
+ if (existing !== undefined)
86
+ return existing;
87
+ loadMaxTokensCaps(dataDir, now);
88
+ const unsubscribe = onMaxTokensCap((key, cap) => recordMaxTokensCap(dataDir, key, cap, now));
89
+ const detach = () => {
90
+ unsubscribe();
91
+ attached.delete(dataDir);
92
+ };
93
+ attached.set(dataDir, detach);
94
+ return detach;
95
+ }
@@ -256,9 +256,9 @@ export class ProviderRegistry {
256
256
  const channelsOf = (wanted, modelId) => modelChannels(wanted, provider.channels ?? [], provider.defaultChannel ?? "", (name) => this.warn(`model "${id}/${modelId}" channel "${name}" not found; ignored`));
257
257
  for (const entry of config.models ?? []) {
258
258
  // 模型级 api(第三波 §2.3):同一中转下不同模型走不同协议;缺省沿用供应商(或渠道)的。
259
- const { entry: filled, metadata } = enrichEntry(entry, this.modelsDev);
260
- if (typeof entry.modelsDev === "string" && metadata.looked && metadata.match === undefined)
261
- this.warn(`modelsDev "${entry.modelsDev}" for "${id}/${entry.id}" not found`);
259
+ const { entry: filled, metadata } = enrichEntry(entry, this.modelsDev, id);
260
+ for (const warning of metadata.warnings ?? [])
261
+ this.warn(warning);
262
262
  const model = withCustomDefaults(filled, id, entry.api ?? provider.api);
263
263
  // 内置供应商上没写 channels 的模型:定渠道时挂全部渠道(settleBuiltinChannels)
264
264
  if (provider.channels !== undefined && (entry.channels !== undefined || !provider.builtin))