@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
@@ -16,8 +16,10 @@
16
16
  export declare const SHADOW_DIR = "shadow";
17
17
  /** cwd 下(不含忽略的)文件数上限,超出本会话降级为 tools。 */
18
18
  export declare const SHADOW_MAX_FILES = 20000;
19
- /** 单次快照耗时上限(毫秒),超出本会话降级为 tools。 */
19
+ /** 单次快照耗时上限(毫秒),连续 `SHADOW_SLOW_STREAK` 次超出本会话降级为 tools。 */
20
20
  export declare const SHADOW_MAX_SNAPSHOT_MS = 3000;
21
+ /** 连续这么多次快照超时才降级(首次快照不计)。 */
22
+ export declare const SHADOW_SLOW_STREAK = 2;
21
23
  export declare function shadowRoot(dataDir: string): string;
22
24
  /** cwd 对应的影子仓库目录。 */
23
25
  export declare function shadowRepoDir(dataDir: string, cwd: string): string;
@@ -73,6 +75,8 @@ export declare class ShadowRepo {
73
75
  private readonly indexFile;
74
76
  private initialized;
75
77
  private counted;
78
+ /** 连续超时的快照数(首次快照不计),到 `SHADOW_SLOW_STREAK` 才降级。 */
79
+ private slowStreak;
76
80
  constructor(dataDir: string, cwd: string, options?: ShadowGitOptions);
77
81
  private now;
78
82
  private env;
@@ -23,8 +23,10 @@ import { msg } from "../i18n/index.js";
23
23
  export const SHADOW_DIR = "shadow";
24
24
  /** cwd 下(不含忽略的)文件数上限,超出本会话降级为 tools。 */
25
25
  export const SHADOW_MAX_FILES = 20_000;
26
- /** 单次快照耗时上限(毫秒),超出本会话降级为 tools。 */
26
+ /** 单次快照耗时上限(毫秒),连续 `SHADOW_SLOW_STREAK` 次超出本会话降级为 tools。 */
27
27
  export const SHADOW_MAX_SNAPSHOT_MS = 3_000;
28
+ /** 连续这么多次快照超时才降级(首次快照不计)。 */
29
+ export const SHADOW_SLOW_STREAK = 2;
28
30
  /** 单个 git 进程的硬上限,防止卡死。 */
29
31
  const GIT_TIMEOUT_MS = 60_000;
30
32
  /** 本地配置的版本;改了配置项就加一,旧仓库会补写。 */
@@ -85,6 +87,8 @@ export class ShadowRepo {
85
87
  indexFile;
86
88
  initialized;
87
89
  counted = false;
90
+ /** 连续超时的快照数(首次快照不计),到 `SHADOW_SLOW_STREAK` 才降级。 */
91
+ slowStreak = 0;
88
92
  constructor(dataDir, cwd, options = {}) {
89
93
  this.cwd = resolve(cwd);
90
94
  this.dir = shadowRepoDir(dataDir, this.cwd);
@@ -207,6 +211,8 @@ export class ShadowRepo {
207
211
  await this.init();
208
212
  // 计时不含一次性的建仓与本地配置(十来个 git 子进程,Windows 上可达数秒),只量快照本身
209
213
  const started = this.now();
214
+ // 首次快照要把整个工作区读进索引,天然偏慢,不计入超时
215
+ const first = !this.counted;
210
216
  await this.writeExcludes();
211
217
  if (!this.counted) {
212
218
  const out = await this.run([
@@ -233,7 +239,10 @@ export class ShadowRepo {
233
239
  args.push("-p", parent);
234
240
  const commit = await this.text(args);
235
241
  const elapsed = this.now() - started;
236
- if (elapsed > this.maxSnapshotMs) {
242
+ if (first)
243
+ return { commit };
244
+ this.slowStreak = elapsed > this.maxSnapshotMs ? this.slowStreak + 1 : 0;
245
+ if (this.slowStreak >= SHADOW_SLOW_STREAK) {
237
246
  return {
238
247
  commit,
239
248
  degrade: {
@@ -14,6 +14,7 @@
14
14
  * `originator` 头;OAuth 刷新拿到 `auth.chatgpt` 配置与同一份 env。
15
15
  * - 发现缓存(`<dataDir>/models/discovered/<provider>.json`)并入模型表为空的供应商(discovered-cache.ts);
16
16
  * chatgpt 的缓存 flavor 与当前登录不符时视为过期、不并入。
17
+ * - max_tokens 上限缓存(`<dataDir>/models/max-tokens-caps.json`,30 天)载回进程内的表并订阅写回(#152)。
17
18
  */
18
19
  import type { ApiRegistry } from "../ai/apis/api.js";
19
20
  import { ProviderRegistry } from "../ai/providers/registry.js";
@@ -14,10 +14,12 @@
14
14
  * `originator` 头;OAuth 刷新拿到 `auth.chatgpt` 配置与同一份 env。
15
15
  * - 发现缓存(`<dataDir>/models/discovered/<provider>.json`)并入模型表为空的供应商(discovered-cache.ts);
16
16
  * chatgpt 的缓存 flavor 与当前登录不符时视为过期、不并入。
17
+ * - max_tokens 上限缓存(`<dataDir>/models/max-tokens-caps.json`,30 天)载回进程内的表并订阅写回(#152)。
17
18
  */
18
19
  import { loadModelsDevIndex } from "../ai/providers/models-dev-cache.js";
19
20
  import { ProviderRegistry, discoverLocalModels } from "../ai/providers/registry.js";
20
21
  import { mergeDiscoveredModels } from "../ai/providers/discovered-cache.js";
22
+ import { attachMaxTokensCache } from "../ai/providers/max-tokens-cache.js";
21
23
  import { lazyIndex } from "../ai/providers/enrich.js";
22
24
  import { readOAuthEntry } from "../auth/oauth/token-store.js";
23
25
  import { CHATGPT_BASE_URLS, CHATGPT_PROVIDER_ID } from "../auth/chatgpt/presets.js";
@@ -110,6 +112,7 @@ export async function buildProviderRegistry(input, options = {}) {
110
112
  }
111
113
  // 发现缓存(`ama auth login chatgpt` / `ama models discover chatgpt`)并入模型表为空的供应商
112
114
  if (dataDir !== undefined) {
115
+ attachMaxTokensCache(dataDir);
113
116
  const flavor = readOAuthEntry(input.authFile, CHATGPT_PROVIDER_ID)?.flavor;
114
117
  mergeDiscoveredModels(registry, dataDir, lazyIndex(() => loadModelsDevIndex(dataDir)), (id) => (id === CHATGPT_PROVIDER_ID ? flavor : undefined));
115
118
  }
@@ -6,16 +6,19 @@
6
6
  * - `resume{id}` / `fork{id}`:先在本目录、再在全部子目录里按 id 或唯一前缀找文件;找不到 →
7
7
  * `session_not_found`,前缀不唯一 → `invalid_arguments` 并列出候选;
8
8
  * - `session-id{id}`:找到则打开,否则以该 id 新建;
9
- * - `fork{id}`:打开后从叶子复制出新文件,原会话立即关闭(释放锁)。
9
+ * - `fork{id}`:打开后从叶子复制出新文件,原会话立即关闭(释放锁);源会话不属于任何 AgentSession,
10
+ * 它的告警(图片读不回,#183)显式交给 `context.log`。
10
11
  */
11
12
  import { SessionManager } from "../session/manager.js";
12
13
  import type { SessionListItem } from "../session/types.js";
14
+ import type { LogFn } from "./compose-session.js";
13
15
  import type { RuntimeDeps, SessionRequest } from "./deps.js";
14
16
  /** 按 id / 唯一前缀找会话文件;本 cwd 目录优先。 */
15
17
  export declare function findSessionFile(root: string, id: string, cwd?: string): string | undefined;
16
18
  export declare function openSession(request: SessionRequest, context: {
17
19
  sessionDir: string;
18
20
  cwd: string;
21
+ log?: LogFn | undefined;
19
22
  }): SessionManager;
20
23
  /** 列会话(最新在前);子 Agent 会话缺省跳过,`includeSubagents` 时保留(带 `subagent` 标记)。 */
21
24
  export declare function listSessions(context: {
@@ -23,4 +26,5 @@ export declare function listSessions(context: {
23
26
  cwd?: string;
24
27
  includeSubagents?: boolean;
25
28
  }): SessionListItem[];
26
- export declare function createSessionStore(): RuntimeDeps["sessions"];
29
+ /** `log`:`--fork` 源会话的告警出口(通常是会话的 `options.log`)。 */
30
+ export declare function createSessionStore(log?: LogFn): RuntimeDeps["sessions"];
@@ -6,7 +6,8 @@
6
6
  * - `resume{id}` / `fork{id}`:先在本目录、再在全部子目录里按 id 或唯一前缀找文件;找不到 →
7
7
  * `session_not_found`,前缀不唯一 → `invalid_arguments` 并列出候选;
8
8
  * - `session-id{id}`:找到则打开,否则以该 id 新建;
9
- * - `fork{id}`:打开后从叶子复制出新文件,原会话立即关闭(释放锁)。
9
+ * - `fork{id}`:打开后从叶子复制出新文件,原会话立即关闭(释放锁);源会话不属于任何 AgentSession,
10
+ * 它的告警(图片读不回,#183)显式交给 `context.log`。
10
11
  */
11
12
  import { existsSync, readdirSync, statSync } from "node:fs";
12
13
  import { join } from "node:path";
@@ -81,7 +82,9 @@ export function openSession(request, context) {
81
82
  : SessionManager.open(file);
82
83
  }
83
84
  case "fork": {
84
- const source = SessionManager.open(requireFile(root, request.id, cwd));
85
+ const source = SessionManager.open(requireFile(root, request.id, cwd), {
86
+ warn: (message) => context.log?.("warn", message),
87
+ });
85
88
  try {
86
89
  const leaf = source.leafId();
87
90
  if (leaf === null)
@@ -104,9 +107,10 @@ export function listSessions(context) {
104
107
  .filter((item) => context.includeSubagents === true || item.subagent !== true)
105
108
  .sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
106
109
  }
107
- export function createSessionStore() {
110
+ /** `log`:`--fork` 源会话的告警出口(通常是会话的 `options.log`)。 */
111
+ export function createSessionStore(log) {
108
112
  return {
109
- open: (request, context) => openSession(request, context),
113
+ open: (request, context) => openSession(request, { ...context, log }),
110
114
  list: async (context) => listSessions(context),
111
115
  show: async (id, context) => {
112
116
  const file = requireFile(context.sessionDir, id);
@@ -40,7 +40,7 @@ import { builtinTools } from "../tools/registry.js";
40
40
  import { MEMORY_TOOL, createMemoryTool } from "../memory/tool.js";
41
41
  import { takeCodemodeNotice } from "./codemode-notice.js";
42
42
  import { buildProviderRegistry } from "./compose-providers.js";
43
- import { composeSession, emptyComposeState, } from "./compose-session.js";
43
+ import { composeSession, emptyComposeState, stderrLog, } from "./compose-session.js";
44
44
  import { createSessionStore } from "./compose-store.js";
45
45
  import { defaultSendUser } from "./startup-steps.js";
46
46
  import { imageFitOptionsFor } from "../modes/image-input.js";
@@ -211,7 +211,7 @@ export function createRuntimeDeps(options = {}) {
211
211
  return registry;
212
212
  },
213
213
  },
214
- sessions: createSessionStore(),
214
+ sessions: createSessionStore(options.log ?? stderrLog()),
215
215
  tools: { create: (input) => createTools(input, options, state) },
216
216
  permissions: {
217
217
  create: (input) => new PermissionPipeline({
@@ -161,6 +161,7 @@ function w5Sections() {
161
161
  background: oneOf(SUBAGENT_BACKGROUND_MODES),
162
162
  autoBackgroundAfterMs: num(0),
163
163
  retainSessions: num(0),
164
+ forkMaxContextRatio: num(0.05, 0.95),
164
165
  }),
165
166
  models: object({ aliases: object({ fast: str(), strong: str() }), enabled: strings }),
166
167
  fallbackModel: str(),
@@ -65,6 +65,7 @@ const IMPLICIT_DEFAULTS = {
65
65
  background: "auto",
66
66
  autoBackgroundAfterMs: 0,
67
67
  retainSessions: 4,
68
+ forkMaxContextRatio: 0.5,
68
69
  },
69
70
  reminders: { todo: true, fileChanges: true, contextPressure: true, budget: true },
70
71
  todo: { reminder: 10 },
@@ -90,6 +90,7 @@ export function validateConfigW5(c, config) {
90
90
  "background",
91
91
  "autoBackgroundAfterMs",
92
92
  "retainSessions",
93
+ "forkMaxContextRatio",
93
94
  ], (s, p) => {
94
95
  c.number(s, "maxConcurrent", p, 1, 64);
95
96
  c.number(s, "maxPending", p, 0, 1024);
@@ -97,6 +98,7 @@ export function validateConfigW5(c, config) {
97
98
  c.oneOf(s, "background", p, SUBAGENT_BACKGROUND_MODES);
98
99
  c.number(s, "autoBackgroundAfterMs", p, 0);
99
100
  c.number(s, "retainSessions", p, 0);
101
+ c.number(s, "forkMaxContextRatio", p, 0.05, 0.95);
100
102
  });
101
103
  checkSection(c, config, "models", ["aliases", "enabled"], (s, p) => {
102
104
  c.stringArray(s, "enabled", p);
@@ -101,6 +101,7 @@ const ROWS = [
101
101
  row("subagents.maxConcurrent", "agents", "nextSession", "deny"),
102
102
  row("subagents.maxPending", "agents", "nextSession", "deny"),
103
103
  row("subagents.retainSessions", "agents", "nextSession", "deny"),
104
+ row("subagents.forkMaxContextRatio", "agents", "nextSession", "deny"),
104
105
  // [W7-B2] 改 task 工具描述(缺省后台 / 前台两种文案),故 prefix
105
106
  row("subagents.background", "agents", "restart", "any", { prefix: true }),
106
107
  row("subagents.autoBackgroundAfterMs", "agents", "restart", "any"),
@@ -79,6 +79,11 @@ export interface SubagentsConfig {
79
79
  autoBackgroundAfterMs?: number;
80
80
  /** [M-F] 已结束子会话保留在内存的句柄数(LRU),缺省 4;超出的续聊时从会话文件重开。只认用户级。 */
81
81
  retainSessions?: number;
82
+ /**
83
+ * [#149] fork 回落比例:父上一次请求的输入 token 超过(窗口 − compaction.reserveTokens)× 本值时
84
+ * fork 回落为 fresh;0.05–0.95,缺省 0.5。只认用户级。
85
+ */
86
+ forkMaxContextRatio?: number;
82
87
  }
83
88
  /** `models.enabled` 的一项:`provider/model[@channel]` 或 `provider/*`。 */
84
89
  export declare const MODELS_ENABLED_REF: RegExp;
@@ -534,6 +534,7 @@ declare const en: {
534
534
  backgroundStopped: (jobId: string, command: string) => string;
535
535
  compactionFailed: (error: string) => string;
536
536
  compactionCancelled: string;
537
+ imageReadBackFailed: (id: string) => string;
537
538
  };
538
539
  statusLine: {
539
540
  usage: string;
@@ -1446,6 +1447,7 @@ declare const en: {
1446
1447
  "subagents.maxPending": string;
1447
1448
  "subagents.retainSessions": string;
1448
1449
  "subagents.defaultModel": string;
1450
+ "subagents.forkMaxContextRatio": string;
1449
1451
  "subagents.background": string;
1450
1452
  "subagents.autoBackgroundAfterMs": string;
1451
1453
  models: string;
@@ -115,6 +115,7 @@ export declare const en: {
115
115
  "subagents.maxPending": string;
116
116
  "subagents.retainSessions": string;
117
117
  "subagents.defaultModel": string;
118
+ "subagents.forkMaxContextRatio": string;
118
119
  "subagents.background": string;
119
120
  "subagents.autoBackgroundAfterMs": string;
120
121
  models: string;
@@ -269,6 +270,7 @@ export declare const zh: {
269
270
  "subagents.maxPending": string;
270
271
  "subagents.retainSessions": string;
271
272
  "subagents.defaultModel": string;
273
+ "subagents.forkMaxContextRatio": string;
272
274
  "subagents.background": string;
273
275
  "subagents.autoBackgroundAfterMs": string;
274
276
  models: string;
@@ -164,6 +164,7 @@ const keysEn = {
164
164
  "subagents.maxPending": "Queue limit; task fails beyond it",
165
165
  "subagents.retainSessions": "Finished sub-sessions kept in memory (least recently used first out); others reopen from their session file when continued",
166
166
  "subagents.defaultModel": "Default sub-session model provider/model[@channel]",
167
+ "subagents.forkMaxContextRatio": "Fork sub-agents fall back to fresh when the parent's last request used more than this share of (window − compaction.reserveTokens); 0.05–0.95, default 0.5",
167
168
  "subagents.background": "Whether task runs in the background by default: auto = background in the TUI / RPC / ACP and foreground with -p; always / never are fixed. The call argument and the agent type's background: override it; user, project and host level",
168
169
  "subagents.autoBackgroundAfterMs": "Move a foreground task to the background after it has run this many milliseconds; 0 disables; user and project level",
169
170
  models: "Model aliases and the model picker list; user level only (effective from wave 5 W5-G)",
@@ -292,6 +293,7 @@ const keysZh = {
292
293
  "subagents.maxPending": "排队上限,超出时 task 报错",
293
294
  "subagents.retainSessions": "内存中保留的已结束子会话数(最久未用的先释放),其余续聊时从会话文件重开",
294
295
  "subagents.defaultModel": "子会话缺省模型 provider/model[@channel]",
296
+ "subagents.forkMaxContextRatio": "fork 子 Agent 的回落比例:父上一次请求超过(窗口 − compaction.reserveTokens)的这一比例时改为 fresh;0.05–0.95,缺省 0.5",
295
297
  "subagents.background": "task 缺省是否后台:auto = TUI / RPC / ACP 下后台、-p 下前台;always / never 固定。调用参数与类型定义的 background: 优先;用户、项目、宿主级都认",
296
298
  "subagents.autoBackgroundAfterMs": "前台任务运行超过该毫秒数自动转后台;0 关闭;用户、项目级都认",
297
299
  models: "模型别名与模型选择器清单;只认用户级(第五波 W5-G 起生效)",
@@ -115,6 +115,7 @@ export declare const en: {
115
115
  "subagents.maxPending": string;
116
116
  "subagents.retainSessions": string;
117
117
  "subagents.defaultModel": string;
118
+ "subagents.forkMaxContextRatio": string;
118
119
  "subagents.background": string;
119
120
  "subagents.autoBackgroundAfterMs": string;
120
121
  models: string;
@@ -383,6 +384,7 @@ export declare const zh: {
383
384
  "subagents.maxPending": string;
384
385
  "subagents.retainSessions": string;
385
386
  "subagents.defaultModel": string;
387
+ "subagents.forkMaxContextRatio": string;
386
388
  "subagents.background": string;
387
389
  "subagents.autoBackgroundAfterMs": string;
388
390
  models: string;
@@ -85,6 +85,7 @@ export declare const en: {
85
85
  backgroundStopped: (jobId: string, command: string) => string;
86
86
  compactionFailed: (error: string) => string;
87
87
  compactionCancelled: string;
88
+ imageReadBackFailed: (id: string) => string;
88
89
  };
89
90
  statusLine: {
90
91
  usage: string;
@@ -335,6 +336,7 @@ export declare const zh: {
335
336
  backgroundStopped: (jobId: string, command: string) => string;
336
337
  compactionFailed: (error: string) => string;
337
338
  compactionCancelled: string;
339
+ imageReadBackFailed: (id: string) => string;
338
340
  };
339
341
  statusLine: {
340
342
  usage: string;
@@ -99,6 +99,7 @@ export const en = {
99
99
  backgroundStopped: (jobId, command) => `Background command ${jobId} stopped: ${command}`,
100
100
  compactionFailed: (error) => `Compaction failed: ${error}`,
101
101
  compactionCancelled: "Compaction cancelled",
102
+ imageReadBackFailed: (id) => `Session image for entry ${id} could not be read back (file changed); it stays empty`,
102
103
  },
103
104
  statusLine: {
104
105
  usage: "Usage: /statusline [full|compact]",
@@ -234,6 +235,7 @@ export const zh = {
234
235
  backgroundStopped: (jobId, command) => `后台命令 ${jobId} 已停止:${command}`,
235
236
  compactionFailed: (error) => `压缩失败:${error}`,
236
237
  compactionCancelled: "压缩已取消",
238
+ imageReadBackFailed: (id) => `会话文件已改变,条目 ${id} 的图片读不回,保留为空`,
237
239
  },
238
240
  statusLine: {
239
241
  usage: "用法:/statusline [full|compact]",
@@ -88,7 +88,7 @@ export const en = {
88
88
  cwdIsHome: "the working directory is the home directory",
89
89
  gitNotFound: "git not found",
90
90
  tooManyFiles: (files, max) => `the working directory has ${plural(files, "file")} (limit ${max})`,
91
- tooSlow: (seconds, max) => `the snapshot took ${seconds} s (limit ${max} s)`,
91
+ tooSlow: (seconds, max) => `snapshots took longer than ${max} s twice in a row (latest ${seconds} s)`,
92
92
  gitFailed: (command, code, detail) => `git ${command} failed (${code})${detail === "" ? "" : `: ${detail}`}`,
93
93
  noShadowCommit: "the checkpoint has no shadow commit",
94
94
  shadowCommitMissing: (commit) => `shadow commit ${commit} does not exist`,
@@ -211,7 +211,7 @@ export const zh = {
211
211
  cwdIsHome: "工作目录是家目录",
212
212
  gitNotFound: "找不到 git",
213
213
  tooManyFiles: (files, max) => `工作目录有 ${files} 个文件(上限 ${max})`,
214
- tooSlow: (seconds, max) => `快照用了 ${seconds} 秒(上限 ${max} 秒)`,
214
+ tooSlow: (seconds, max) => `连续两次快照超过 ${max} 秒(最近一次 ${seconds} 秒)`,
215
215
  gitFailed: (command, code, detail) => `git ${command} 失败(${code})${detail === "" ? "" : `:${detail}`}`,
216
216
  noShadowCommit: "检查点没有影子提交",
217
217
  shadowCommitMissing: (commit) => `影子提交 ${commit} 不存在`,
@@ -45,6 +45,8 @@ export function agentCommandHooks(get) {
45
45
  };
46
46
  }
47
47
  const TASK_NOTICE = /^\[task (t\d+)\] ([\s\S]*)$/;
48
+ /** #183:会话图片读不回(`ImageOffload` 的告警)转通知区。 */
49
+ const SESSION_NOTICE = /^cannot read session entry (\S+) back from /;
48
50
  export class AgentUi {
49
51
  deps;
50
52
  plan;
@@ -164,6 +166,11 @@ export class AgentUi {
164
166
  this.deps.notice(level === "warn" ? "warn" : "info", `[${agent} · ${taskId}] ${match[2]}`);
165
167
  return;
166
168
  }
169
+ const image = level === "warn" ? SESSION_NOTICE.exec(message) : null;
170
+ if (image !== null) {
171
+ this.deps.notice("warn", msg().interactive.events.imageReadBackFailed(image[1]));
172
+ return;
173
+ }
167
174
  original?.call(holder, level, message);
168
175
  };
169
176
  holder.log = routed;
@@ -37,7 +37,7 @@ export declare class SessionManager implements SessionManagerApi {
37
37
  private closed;
38
38
  private readonly now;
39
39
  private readonly images;
40
- private readonly warn;
40
+ private warn;
41
41
  /** 已知的会话文件字节数(新行的行首偏移)。 */
42
42
  private fileBytes;
43
43
  private constructor();
@@ -65,6 +65,8 @@ export declare class SessionManager implements SessionManagerApi {
65
65
  private syncImages;
66
66
  /** 原文条目(已卸载的从文件回读一份副本)。 */
67
67
  private original;
68
+ /** 接上(或换掉)告警出口(图片读不回等);`open()` 期间缓冲的告警随即按序冲出(#183)。 */
69
+ setWarn(warn: (message: string) => void): void;
68
70
  /** 已卸载图片的条目数(测试与诊断用)。 */
69
71
  offloadedCount(): number;
70
72
  private leafLine;
@@ -182,6 +182,11 @@ export class SessionManager {
182
182
  return entry;
183
183
  return this.images.original(this.storage.file, entry);
184
184
  }
185
+ /** 接上(或换掉)告警出口(图片读不回等);`open()` 期间缓冲的告警随即按序冲出(#183)。 */
186
+ setWarn(warn) {
187
+ this.warn = warn;
188
+ this.images.setWarn(warn);
189
+ }
185
190
  /** 已卸载图片的条目数(测试与诊断用)。 */
186
191
  offloadedCount() {
187
192
  return this.images.size;
@@ -10,7 +10,8 @@
10
10
  * `content` 换新数组,image 块换成 `data: ""` 的占位;
11
11
  * - 回读:按行位置 `readSync` 一行、`JSON.parse`,校验 id 与内容块数,图片经 `internImage` 驻留后
12
12
  * **就地**放回占位所在的数组(之前拿到同一消息对象的调用方随之看到原图);读不回(文件被外部改写)时
13
- * 保留 `""` 并告警,不再重试;
13
+ * 保留 `""` 并告警,不再重试;告警通道可后接(`setWarn`):接上之前的告警先缓冲(最多
14
+ * `PENDING_WARNINGS` 条,其余折叠成一条计数),接上时按序冲出(#183);
14
15
  * - `original(entry)`:给 `getEntries` / `fork` 的原文副本,不改内存状态。
15
16
  */
16
17
  import type { SessionEntry } from "./types.js";
@@ -25,12 +26,18 @@ export declare function hasImages(entry: SessionEntry): boolean;
25
26
  export declare function readLineAt(file: string, locator: LineLocator): unknown;
26
27
  /** 文件里全部 `context_edit` 的目标 id(只解析 context_edit 行;读不了时为空集)。 */
27
28
  export declare function editTargets(file: string): Set<string>;
29
+ /** 告警通道接上之前最多缓冲的条数;超出的只计数,冲出时折叠成一条。 */
30
+ export declare const PENDING_WARNINGS = 16;
28
31
  export declare class ImageOffload {
29
- private readonly warn;
30
32
  /** 含图 message 条目的行位置(只有已落盘的条目才有)。 */
31
33
  private readonly locators;
32
34
  private readonly offloaded;
35
+ private warn;
36
+ private readonly pending;
37
+ private dropped;
33
38
  constructor(warn?: (message: string) => void);
39
+ /** 接上(或换掉)告警通道;之前缓冲的告警按序冲出。 */
40
+ setWarn(warn: (message: string) => void): void;
34
41
  /** 记下条目的行位置;不含图片的条目忽略。 */
35
42
  track(entry: SessionEntry, locator: LineLocator): void;
36
43
  /** 读文件时逐行调用:记下行位置;`strip` 时就地换成占位(刚解析的对象,别处没有引用)。 */
@@ -48,4 +55,5 @@ export declare class ImageOffload {
48
55
  sync(file: string, branch: readonly SessionEntry[], index: ReadonlyMap<string, SessionEntry>): void;
49
56
  /** 从文件读回条目(新对象);不成功时告警、忘掉行位置并返回 undefined。 */
50
57
  private read;
58
+ private report;
51
59
  }
@@ -10,7 +10,8 @@
10
10
  * `content` 换新数组,image 块换成 `data: ""` 的占位;
11
11
  * - 回读:按行位置 `readSync` 一行、`JSON.parse`,校验 id 与内容块数,图片经 `internImage` 驻留后
12
12
  * **就地**放回占位所在的数组(之前拿到同一消息对象的调用方随之看到原图);读不回(文件被外部改写)时
13
- * 保留 `""` 并告警,不再重试;
13
+ * 保留 `""` 并告警,不再重试;告警通道可后接(`setWarn`):接上之前的告警先缓冲(最多
14
+ * `PENDING_WARNINGS` 条,其余折叠成一条计数),接上时按序冲出(#183);
14
15
  * - `original(entry)`:给 `getEntries` / `fork` 的原文副本,不改内存状态。
15
16
  */
16
17
  import { closeSync, openSync, readSync } from "node:fs";
@@ -74,14 +75,28 @@ export function editTargets(file) {
74
75
  return targets;
75
76
  }
76
77
  const placeholder = (block) => ({ ...block, data: "" });
78
+ /** 告警通道接上之前最多缓冲的条数;超出的只计数,冲出时折叠成一条。 */
79
+ export const PENDING_WARNINGS = 16;
77
80
  export class ImageOffload {
78
- warn;
79
81
  /** 含图 message 条目的行位置(只有已落盘的条目才有)。 */
80
82
  locators = new Map();
81
83
  offloaded = new Set();
82
- constructor(warn = () => { }) {
84
+ warn;
85
+ pending = [];
86
+ dropped = 0;
87
+ constructor(warn) {
83
88
  this.warn = warn;
84
89
  }
90
+ /** 接上(或换掉)告警通道;之前缓冲的告警按序冲出。 */
91
+ setWarn(warn) {
92
+ this.warn = warn;
93
+ const pending = this.pending.splice(0);
94
+ if (this.dropped > 0)
95
+ pending.push(`and ${this.dropped} more session entries could not be read back`);
96
+ this.dropped = 0;
97
+ for (const message of pending)
98
+ warn(message);
99
+ }
85
100
  /** 记下条目的行位置;不含图片的条目忽略。 */
86
101
  track(entry, locator) {
87
102
  if (hasImages(entry))
@@ -188,8 +203,16 @@ export class ImageOffload {
188
203
  catch (error) {
189
204
  this.locators.delete(entry.id);
190
205
  this.offloaded.delete(entry.id);
191
- this.warn(`cannot read session entry ${entry.id} back from ${file}: ${String(error)}`);
206
+ this.report(`cannot read session entry ${entry.id} back from ${file}: ${String(error)}`);
192
207
  return undefined;
193
208
  }
194
209
  }
210
+ report(message) {
211
+ if (this.warn !== undefined)
212
+ this.warn(message);
213
+ else if (this.pending.length < PENDING_WARNINGS)
214
+ this.pending.push(message);
215
+ else
216
+ this.dropped++;
217
+ }
195
218
  }
@@ -5,14 +5,14 @@
5
5
  * 这里只拿到 ChildProcess;stdout / stderr 直接写进 `outputPath`(文件描述符交给子进程,ama 退出也不
6
6
  * 会因管道断开而 SIGPIPE)。POSIX 自成进程组并登记(进程退出时同步 SIGKILL 残留组)。
7
7
  * - `wait(id, timeoutMs)`:等到退出或超时;`stop(id)`:杀整棵树(SIGTERM → 宽限 → SIGKILL);
8
- * `output(id)`:读输出文件尾部(2000 行 / 50 KB)。
8
+ * `output(id, options?)`:读输出文件尾部(缺省 2000 行 / 50 KB,调用方可传字节上限)。
9
9
  * - 生命周期事件经 `onChange` 发出(started / exited / stopped);`takeExited()` 取走尚未通知的已退出
10
10
  * 任务(提醒通道 reminders.ts 据此告诉模型「后台命令 jobId 已退出(码 N)」)。
11
11
  * - 按会话 id 登记(`jobsForSession`);`disposeSessionJobs` 在会话 dispose 时回收进程树。
12
12
  */
13
13
  import type { ChildProcess } from "node:child_process";
14
14
  import { type ProcessDeps } from "./process-tree.js";
15
- import { type TruncateResult } from "./truncate.js";
15
+ import { type TruncateOptions, type TruncateResult } from "./truncate.js";
16
16
  export type JobStatus = "running" | "exited" | "stopped";
17
17
  export interface Job {
18
18
  id: string;
@@ -49,8 +49,8 @@ export declare class BackgroundJobs {
49
49
  /** 等到退出或超时(`signal` 可提前结束等待,不杀进程)。 */
50
50
  wait(id: string, timeoutMs: number, signal?: AbortSignal): Promise<Job | undefined>;
51
51
  stop(id: string): Promise<Job | undefined>;
52
- /** 输出文件尾部(2000 行 / 50 KB)。 */
53
- output(id: string): (TruncateResult & {
52
+ /** 输出文件尾部(缺省 2000 行 / 50 KB;调用方可传字节上限,#155)。 */
53
+ output(id: string, options?: TruncateOptions): (TruncateResult & {
54
54
  job: Job;
55
55
  }) | undefined;
56
56
  /** 取走已退出、尚未通知的任务(提醒通道用;wait 已看到退出的也算已知)。 */
@@ -5,7 +5,7 @@
5
5
  * 这里只拿到 ChildProcess;stdout / stderr 直接写进 `outputPath`(文件描述符交给子进程,ama 退出也不
6
6
  * 会因管道断开而 SIGPIPE)。POSIX 自成进程组并登记(进程退出时同步 SIGKILL 残留组)。
7
7
  * - `wait(id, timeoutMs)`:等到退出或超时;`stop(id)`:杀整棵树(SIGTERM → 宽限 → SIGKILL);
8
- * `output(id)`:读输出文件尾部(2000 行 / 50 KB)。
8
+ * `output(id, options?)`:读输出文件尾部(缺省 2000 行 / 50 KB,调用方可传字节上限)。
9
9
  * - 生命周期事件经 `onChange` 发出(started / exited / stopped);`takeExited()` 取走尚未通知的已退出
10
10
  * 任务(提醒通道 reminders.ts 据此告诉模型「后台命令 jobId 已退出(码 N)」)。
11
11
  * - 按会话 id 登记(`jobsForSession`);`disposeSessionJobs` 在会话 dispose 时回收进程树。
@@ -119,8 +119,8 @@ export class BackgroundJobs {
119
119
  await entry.done;
120
120
  return entry.job;
121
121
  }
122
- /** 输出文件尾部(2000 行 / 50 KB)。 */
123
- output(id) {
122
+ /** 输出文件尾部(缺省 2000 行 / 50 KB;调用方可传字节上限,#155)。 */
123
+ output(id, options = {}) {
124
124
  const entry = this.entries.get(id);
125
125
  if (entry === undefined)
126
126
  return undefined;
@@ -132,7 +132,7 @@ export class BackgroundJobs {
132
132
  catch {
133
133
  // 输出文件被删:按空输出
134
134
  }
135
- return { ...truncateTail(text), job: entry.job };
135
+ return { ...truncateTail(text, options), job: entry.job };
136
136
  }
137
137
  /** 取走已退出、尚未通知的任务(提醒通道用;wait 已看到退出的也算已知)。 */
138
138
  takeExited() {
@@ -9,7 +9,8 @@
9
9
  * - 注入 `AMA_*` 环境变量(shell.ts)。shell 退出后不等仍占着管道的后台进程。
10
10
  * - [W5-H2] 后台命令(§8.3 H6,不加新工具):`{ command, background: true }` 立即返回
11
11
  * `{ jobId, outputPath, pid }`;`{ job, action: "wait" | "output" | "stop" }` 查询 / 结束
12
- * (background-jobs.ts,按会话登记,会话 dispose 时回收进程树;退出经提醒通道通知)。
12
+ * (background-jobs.ts,按会话登记,会话 dispose 时回收进程树;退出经提醒通道通知)。查询输出同样按
13
+ * 会话结果上限尾截断(#155)。
13
14
  * - **进程只在 `spawnShell` 里创建**(前台与后台共用)。[S2] `sandbox.bash: auto` 且有能限制写入的
14
15
  * OS 沙箱时在这一处包一层(src/sandbox/bash.ts);`sandbox: false` 不包装(权限管线照常审批)。
15
16
  * 失败输出像是被沙箱拒绝时末尾追加一行说明。
@@ -9,7 +9,8 @@
9
9
  * - 注入 `AMA_*` 环境变量(shell.ts)。shell 退出后不等仍占着管道的后台进程。
10
10
  * - [W5-H2] 后台命令(§8.3 H6,不加新工具):`{ command, background: true }` 立即返回
11
11
  * `{ jobId, outputPath, pid }`;`{ job, action: "wait" | "output" | "stop" }` 查询 / 结束
12
- * (background-jobs.ts,按会话登记,会话 dispose 时回收进程树;退出经提醒通道通知)。
12
+ * (background-jobs.ts,按会话登记,会话 dispose 时回收进程树;退出经提醒通道通知)。查询输出同样按
13
+ * 会话结果上限尾截断(#155)。
13
14
  * - **进程只在 `spawnShell` 里创建**(前台与后台共用)。[S2] `sandbox.bash: auto` 且有能限制写入的
14
15
  * OS 沙箱时在这一处包一层(src/sandbox/bash.ts);`sandbox: false` 不包装(权限管线照常审批)。
15
16
  * 失败输出像是被沙箱拒绝时末尾追加一行说明。
@@ -304,7 +305,7 @@ async function jobAction(input, ctx, options) {
304
305
  }
305
306
  if (action === "stop") {
306
307
  const job = (await jobs.stop(id));
307
- const out = jobs.output(id);
308
+ const out = jobs.output(id, { maxBytes: toolOutputBytes(ctx.maxResultChars) });
308
309
  const text = out?.content ?? "";
309
310
  return {
310
311
  content: `${text === "" ? "(no output)" : text}\n\n${jobStatusLine(job)}`,
@@ -317,7 +318,7 @@ async function jobAction(input, ctx, options) {
317
318
  return fail(`timeoutMs must be between 1 and ${MAX_BASH_TIMEOUT_MS}`);
318
319
  await jobs.wait(id, waitMs, ctx.signal);
319
320
  }
320
- const out = jobs.output(id);
321
+ const out = jobs.output(id, { maxBytes: toolOutputBytes(ctx.maxResultChars) });
321
322
  if (out === undefined)
322
323
  return fail(`Unknown background job ${id}`);
323
324
  const job = out.job;
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { capTaskText, formatTokens, waitDetachedText } from "../agents/result.js";
14
14
  import { taskControl } from "../agents/task-control.js";
15
+ import { nestedTaskRejection } from "./task.js";
15
16
  export const TASK_CTL_TOOL = "task_ctl";
16
17
  export const DEFAULT_WAIT_MS = 30_000;
17
18
  export const MAX_WAIT_MS = 600_000;
@@ -66,7 +67,7 @@ export function createTaskCtlTool() {
66
67
  annotations: { pollable: true },
67
68
  async execute(input, ctx) {
68
69
  if (ctx.depth >= 1)
69
- return fail("task_ctl is not available inside a sub-agent.");
70
+ return fail(nestedTaskRejection(ctx, "task_ctl", "task_ctl is not available inside a sub-agent."));
70
71
  const control = taskControl(ctx.sessionId);
71
72
  if (input.action === "list") {
72
73
  const tasks = control?.list() ?? ctx.tasks?.list() ?? [];