@yeaft/webchat-agent 1.0.527 → 1.0.528

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.
@@ -77,6 +77,7 @@ function runCommand(command, { cwd, timeout, signal, runtimePlatform, runProcess
77
77
  signal,
78
78
  timeoutMs: timeout,
79
79
  maxBytes: MAX_OUTPUT,
80
+ outputLimitAction: 'head-tail',
80
81
  requireExitConfirmation: true,
81
82
  systemdScope: invocation.systemdControl,
82
83
  onSettled: invocation.cleanup || null,
@@ -86,6 +87,7 @@ function runCommand(command, { cwd, timeout, signal, runtimePlatform, runProcess
86
87
  stderr: result.stderr,
87
88
  exitCode: result.code,
88
89
  timedOut: result.timedOut,
90
+ truncated: result.truncated,
89
91
  terminationError: result.terminationError || null,
90
92
  }));
91
93
  }
@@ -103,7 +105,7 @@ Guidelines:
103
105
  - Commands run in the working directory (cwd from context)
104
106
  - Match command syntax to the Agent OS shown in the runtime_platform prompt
105
107
  - Timeout defaults to 2 minutes (max 10 minutes)
106
- - Large outputs are truncated at 256KB
108
+ - Large outputs retain a bounded head/tail (256KB per stream); capture limits do not stop the command. Use background tasks for persistent full logs
107
109
  - Use absolute paths when possible
108
110
  - Avoid interactive commands (no stdin support)
109
111
  - Use background=true for long-running or persistent tasks that should survive across turns
@@ -116,7 +118,7 @@ Guidelines:
116
118
  使用指南:
117
119
  - 命令在工作目录中执行(上下文中的 cwd)
118
120
  - 默认超时 2 分钟(最大 10 分钟)
119
- - 大输出在 256KB 处截断
121
+ - 大输出有界保留首尾(每个流 256KB),不因输出上限终止命令;需要持久完整日志时使用后台任务
120
122
  - 尽量使用绝对路径
121
123
  - 避免交互式命令(不支持 stdin)
122
124
  - 长时间或需要跨 turn 持续存在的任务使用 background=true
@@ -136,8 +138,8 @@ Guidelines:
136
138
  cwd: {
137
139
  type: 'string',
138
140
  description: {
139
- en: 'Working directory for the command (default: engine cwd)',
140
- zh: '命令的工作目录(默认为引擎当前目录)',
141
+ en: 'Working directory; relative paths resolve against engine cwd',
142
+ zh: '命令工作目录;相对路径以引擎当前目录为基准',
141
143
  },
142
144
  },
143
145
  timeout_ms: {
@@ -189,9 +191,7 @@ Guidelines:
189
191
  if (!command) throw new Error('command is required');
190
192
 
191
193
  // Resolve working directory
192
- const cwd = inputCwd
193
- ? resolve(inputCwd)
194
- : (ctx?.cwd || process.cwd());
194
+ const cwd = resolve(ctx?.cwd || process.cwd(), inputCwd || '.');
195
195
 
196
196
  if (!existsSync(cwd)) {
197
197
  throw new Error(`Working directory does not exist: ${cwd}`);
@@ -217,7 +217,7 @@ Guidelines:
217
217
  threadId: ctx.threadId || 'main',
218
218
  },
219
219
  });
220
- return `Started background task ${task.id}.\nStatus: ${task.status}\nLog: ${task.log?.path || ''}\nThe task is detached from this turn. Use ListTasks, ReadTaskLog, or CancelTask to inspect or control it.`;
220
+ return `Started background task ${task.id}.\nWorking directory: ${cwd}\nStatus: ${task.status}\nLog: ${task.log?.path || ''}\nThe task is detached from this turn. Use ListTasks, ReadTaskLog, or CancelTask to inspect or control it.`;
221
221
  } catch (err) {
222
222
  throw new Error(err?.message || String(err));
223
223
  }
@@ -236,6 +236,7 @@ Guidelines:
236
236
  const parts = [];
237
237
  if (result.stdout) parts.push(result.stdout);
238
238
  if (result.stderr) parts.push(`STDERR:\n${result.stderr}`);
239
+ if (result.truncated) parts.push('[Captured output truncated; command exit/timeout status is reported separately. Use a background task for a durable log.]');
239
240
  if (result.timedOut) parts.push(`\n(Command timed out after ${timeout}ms)`);
240
241
  if (result.terminationError) {
241
242
  parts.push([
@@ -258,10 +259,11 @@ Guidelines:
258
259
 
259
260
  const output = parts.join('\n');
260
261
  if (result.exitCode !== 0) {
261
- return `Exit code: ${result.exitCode}\n${output}`;
262
+ return `Exit code: ${result.exitCode}\nWorking directory: ${cwd}\n${output}`;
262
263
  }
263
264
  return output || '(no output)';
264
265
  } catch (err) {
266
+ err.message = `${err.message} (working directory: ${cwd})`;
265
267
  if (err?.name === 'ProcessTerminationError') err.fatalToolTimeout = true;
266
268
  throw err;
267
269
  }
@@ -67,7 +67,7 @@ unless discard_changes is set to true.`,
67
67
  },
68
68
  isDestructive: (input) => input?.action === 'remove',
69
69
  async execute(input, ctx) {
70
- const worktreePath = resolve(input.path);
70
+ const worktreePath = resolve(ctx?.cwd || process.cwd(), input.path);
71
71
  const mainCwd = ctx?.cwd || process.cwd();
72
72
 
73
73
  if (!existsSync(worktreePath)) {
@@ -8,6 +8,7 @@
8
8
  * Modeled after Claude Code's Read tool.
9
9
  */
10
10
 
11
+ import { createHash } from 'node:crypto';
11
12
  import { defineTool } from './types.js';
12
13
  import { readFile, stat } from 'fs/promises';
13
14
  import { existsSync } from 'fs';
@@ -77,6 +78,34 @@ function formatLinesWithinBudget(allLines, startLine, endLine, startColumn = 0,
77
78
  return { text: parts.join(''), nextLine, nextColumn };
78
79
  }
79
80
 
81
+ // Query-local observations are hints only: always read the current file and
82
+ // never suppress ranges. Hashing observed bytes handles same-size/mtime edits.
83
+ function describeRead(ctx, path, content, start, end, startColumn, nextColumn) {
84
+ const version = createHash('sha256').update(content).digest('hex').slice(0, 16);
85
+ const observations = ctx?.fileReadObservations;
86
+ const previous = observations?.get(path);
87
+ const ranges = previous?.version === version ? previous.ranges : [];
88
+ const overlaps = ranges.filter(([from, to]) => from < end && to > start)
89
+ .map(([from, to]) => `${Math.max(from, start) + 1}-${Math.min(to, end)}`);
90
+ // Partial Unicode lines are not considered fully observed.
91
+ const from = start + (startColumn > 0 ? 1 : 0);
92
+ const to = end - (nextColumn > 0 ? 1 : 0);
93
+ if (observations && to > from) {
94
+ const merged = [...ranges, [from, to]].sort((a, b) => a[0] - b[0]).reduce((out, range) => {
95
+ const last = out.at(-1);
96
+ if (last && range[0] <= last[1]) last[1] = Math.max(last[1], range[1]);
97
+ else out.push([...range]);
98
+ return out;
99
+ }, []);
100
+ observations.delete(path);
101
+ observations.set(path, { version, ranges: merged.slice(-16) });
102
+ if (observations.size > 64) observations.delete(observations.keys().next().value);
103
+ }
104
+ const hint = overlaps.length
105
+ ? ` Previously returned unchanged lines in this query: ${overlaps.slice(0, 4).join(', ')}. Read other ranges only if needed.` : '';
106
+ return `\n[File: ${path}; observed version: ${version}.${hint}]`;
107
+ }
108
+
80
109
  export default defineTool({
81
110
  name: 'FileRead',
82
111
  description: {
@@ -140,7 +169,9 @@ Guidelines:
140
169
  },
141
170
  isConcurrencySafe: () => true,
142
171
  isReadOnly: () => true,
143
- cacheWithinQuery: true,
172
+ // The workspace may change outside this Engine (editor, process, peer).
173
+ // Observation hints must describe a fresh read, never a cached snapshot.
174
+ cacheWithinQuery: false,
144
175
  async execute(input, ctx) {
145
176
  const { file_path, offset = 0, column_offset = 0, limit = DEFAULT_LIMIT } = input;
146
177
  if (!file_path) return JSON.stringify({ error: 'file_path is required' });
@@ -201,6 +232,8 @@ Guidelines:
201
232
  const startColumn = column_offset;
202
233
  const { text: numbered, nextLine, nextColumn } = formatLinesWithinBudget(allLines, startLine, endLine, startColumn);
203
234
 
235
+ const shownEnd = nextColumn > 0 ? nextLine + 1 : nextLine;
236
+ const observation = describeRead(ctx, absPath, content, startLine, shownEnd, startColumn, nextColumn);
204
237
  const hasMoreContent = nextColumn > 0 || nextLine < totalLines;
205
238
  if (startLine > 0 || startColumn > 0 || hasMoreContent) {
206
239
  const continuation = hasMoreContent
@@ -208,11 +241,10 @@ Guidelines:
208
241
  ? ` Continue with offset=${nextLine}, column_offset=${nextColumn}.`
209
242
  : ` Continue with offset=${nextLine}.`
210
243
  : '';
211
- const shownEnd = nextColumn > 0 ? nextLine + 1 : nextLine;
212
- return `${numbered}\n\n[Showing lines ${startLine + 1}-${shownEnd} of ${totalLines} total.${continuation}]`;
244
+ return `${numbered}\n\n[Showing lines ${startLine + 1}-${shownEnd} of ${totalLines} total.${continuation}]${observation}`;
213
245
  }
214
246
 
215
- return numbered;
247
+ return numbered + observation;
216
248
  } catch (err) {
217
249
  return JSON.stringify({ error: `Failed to read file: ${err.message}` });
218
250
  }
@@ -46,8 +46,27 @@ async function filterOverrides(run, options) {
46
46
  });
47
47
  }
48
48
 
49
- function errorOutput(message) {
50
- return JSON.stringify({ error: message });
49
+ function errorOutput(message, operation) {
50
+ return JSON.stringify({
51
+ error: message, errorEffect: 'none', code: 'invalid_arguments',
52
+ hint: `Use only fields for the chosen operation. Minimal example: ${JSON.stringify({ operation: ['status', 'diff', 'show', 'log'].includes(operation) ? operation : 'status' })}`,
53
+ });
54
+ }
55
+
56
+ // Some strict-schema providers require every property. Treat their empty
57
+ // placeholders as omitted, without accepting unknown keys or non-default
58
+ // arguments belonging to another operation.
59
+ function normalizeInput(input) {
60
+ const result = { ...input };
61
+ for (const key of ['base', 'head', 'revision', 'paths', 'limit']) {
62
+ if (result[key] === null || result[key] === undefined
63
+ || (['base', 'head', 'revision'].includes(key) && result[key] === '')
64
+ || (key === 'paths' && Array.isArray(result[key]) && result[key].length === 0)
65
+ || (key === 'limit' && result.operation !== 'log' && result[key] === DEFAULT_LOG_LIMIT)) {
66
+ delete result[key];
67
+ }
68
+ }
69
+ return result;
51
70
  }
52
71
 
53
72
  function validateValue(value, name) {
@@ -80,6 +99,7 @@ export function buildGitReadArgs(input) {
80
99
  return { error: 'input must be an object' };
81
100
  }
82
101
 
102
+ input = normalizeInput(input);
83
103
  const { operation } = input;
84
104
  if (!['status', 'diff', 'show', 'log'].includes(operation)) {
85
105
  return { error: 'operation must be one of: status, diff, show, log' };
@@ -202,16 +222,16 @@ GitRead 不 fetch、不写 Git 状态、不创建 worktree。它禁用 pager、e
202
222
  additionalProperties: false,
203
223
  properties: {
204
224
  operation: { type: 'string', enum: ['status', 'diff', 'show', 'log'] },
205
- base: { type: 'string', maxLength: MAX_VALUE_LENGTH, description: 'Diff base revision; omitted for working-tree changes against HEAD' },
206
- head: { type: 'string', maxLength: MAX_VALUE_LENGTH, description: 'Diff head revision; requires base and defaults to HEAD' },
207
- revision: { type: 'string', maxLength: MAX_VALUE_LENGTH, description: 'Revision for show or log (default: HEAD)' },
225
+ base: { type: 'string', maxLength: MAX_VALUE_LENGTH, description: 'diff only; empty/omitted means working-tree changes against HEAD' },
226
+ head: { type: 'string', maxLength: MAX_VALUE_LENGTH, description: 'diff only; requires base, empty/omitted defaults to HEAD' },
227
+ revision: { type: 'string', maxLength: MAX_VALUE_LENGTH, description: 'show/log only; empty/omitted defaults to HEAD' },
208
228
  paths: {
209
229
  type: 'array',
210
230
  maxItems: MAX_PATHS,
211
231
  items: { type: 'string', minLength: 1, maxLength: MAX_VALUE_LENGTH },
212
- description: 'Optional repository-relative paths for diff or show',
232
+ description: 'diff/show only; empty/omitted means all paths',
213
233
  },
214
- limit: { type: 'integer', minimum: 1, maximum: MAX_LOG_LIMIT, description: 'Maximum log entries' },
234
+ limit: { type: 'integer', minimum: 1, maximum: MAX_LOG_LIMIT, description: 'log only; default 20. Empty optional fields are ignored; omit fields for other operations.' },
215
235
  },
216
236
  required: ['operation'],
217
237
  },
@@ -220,7 +240,7 @@ GitRead 不 fetch、不写 Git 状态、不创建 worktree。它禁用 pager、e
220
240
  isReadOnly: () => true,
221
241
  async execute(input, ctx) {
222
242
  const built = buildGitReadArgs(input);
223
- if (built.error) return errorOutput(built.error);
243
+ if (built.error) return errorOutput(built.error, input?.operation);
224
244
  const cwd = resolve(ctx?.cwd || process.cwd());
225
245
  try {
226
246
  const run = ctx?.[RUN_PROCESS_OVERRIDE] || runProcess;
@@ -248,7 +268,7 @@ GitRead 不 fetch、不写 Git 状态、不创建 worktree。它禁用 pager、e
248
268
  return formatGitReadResult(input.operation, result);
249
269
  } catch (error) {
250
270
  if (error?.name === 'AbortError') throw error;
251
- return errorOutput(`GitRead failed: ${error?.message || String(error)}`);
271
+ return JSON.stringify({ error: `GitRead failed: ${error?.message || String(error)}`, resolvedCwd: cwd });
252
272
  }
253
273
  },
254
274
  });
@@ -1,40 +1,36 @@
1
- /**
2
- * list-agents.js — List all active (and optionally terminal) sub-agents.
3
- *
4
- * Returns: { agents: [{ id, name, status, task, outputFile, liveness,
5
- * lastEventAt, msSinceLastEvent, error, hasResult, createdAt }, …] }.
6
- *
7
- * The default filter drops `closed` agents to stay tidy; pass
8
- * include_closed=true (or include_terminal=true) to see them all. The
9
- * include_closed alias is kept for backward-compat with the old shape.
10
- */
1
+ /** Compact, caller-scoped status projection for sub-agent orchestration. */
11
2
 
12
3
  import { defineTool } from './types.js';
13
4
  import { agentBelongsToCaller, getAgentRegistry } from './agent.js';
14
- import { isTerminalAgentStatus } from '../sub-agent/status.js';
5
+ import { isTerminalAgentStatus, STATUS } from '../sub-agent/status.js';
15
6
  import { diagnoseAgentLiveness } from '../sub-agent/liveness.js';
16
7
 
8
+ function nextStepFor(agent, liveness) {
9
+ if (isTerminalAgentStatus(agent.status)) {
10
+ return 'Use WaitAgent to collect the final result, or read outputFile for the full timeline.';
11
+ }
12
+ if (agent.status === STATUS.IDLE) {
13
+ return 'Use WaitAgent to collect the reply; PromptAgent only if follow-up guidance is needed.';
14
+ }
15
+ if (liveness.stale) {
16
+ return 'Diagnostic only: inspect outputFile before deciding whether to CloseAgent; do not assume the work is dead.';
17
+ }
18
+ return 'Continue parent work; completion arrives by notification. Read outputFile only when detailed progress is needed.';
19
+ }
20
+
17
21
  export default defineTool({
18
22
  name: 'ListAgents',
19
23
  description: {
20
- en: `List all sub-agents and their current status.
24
+ en: `List caller-owned sub-agents as compact status references.
21
25
 
22
- Returns id, name, status, mission/task summary, durable outputFile path,
23
- liveness counters (toolUseCount, tokenCount, msSinceLastEvent, recentTools),
24
- stale/stalled diagnostics, result tail, and message count for each agent. Use
25
- this as the primary non-blocking monitor for async sub-agent work, and Read
26
- \`outputFile\` for any single agent if you need its full timeline.
26
+ Returns identity, status, bounded mission summary, durable outputFile, actual tool/LLM/token usage, recent activity, diagnostic staleness, and an actionable next step. It does not copy result text or the full log; use WaitAgent for a reply and Read outputFile for the timeline.
27
27
 
28
- By default only non-closed agents are returned. Pass include_closed=true
29
- to also list closed/failed/abandoned/completed agents.`,
30
- zh: `列出所有子 Agent 及其当前状态。
28
+ By default terminal agents are omitted. Pass include_closed=true to include them.`,
29
+ zh: `以紧凑状态引用列出调用方拥有的子 Agent。
31
30
 
32
- 返回每个 Agent 的 id、name、status、mission/task 摘要、持久化 outputFile 路径、
33
- liveness 计数器(toolUseCount、tokenCount、msSinceLastEvent、recentTools)、
34
- stale/stalled 诊断、result 尾部和消息数量。将此作为异步子 Agent 工作的主要非阻塞监控工具;
35
- 如需查看某个 Agent 的完整时间线,可 Read 其 outputFile。
31
+ 返回身份、状态、有界任务摘要、持久化 outputFile、真实工具/LLM/token 用量、最近活动、诊断性 stale 状态和有效下一步。不复制结果文本或完整日志;回复用 WaitAgent 获取,时间线用 Read outputFile 查看。
36
32
 
37
- 默认只返回未关闭的 Agent。传 include_closed=true 可同时列出 closed/failed/abandoned/completed 的 Agent。`
33
+ 默认省略终止 Agent;传 include_closed=true 可包含。`,
38
34
  },
39
35
  parameters: {
40
36
  type: 'object',
@@ -42,15 +38,15 @@ stale/stalled 诊断、result 尾部和消息数量。将此作为异步子 Agen
42
38
  include_closed: {
43
39
  type: 'boolean',
44
40
  description: {
45
- en: 'Include closed/failed/abandoned/completed agents in the list (default: false)',
46
- zh: '在列表中包含已关闭/失败/放弃/完成的 Agent(默认 false)',
41
+ en: 'Include terminal agents (default: false)',
42
+ zh: '包含终止 Agent(默认 false)',
47
43
  },
48
44
  },
49
45
  include_terminal: {
50
46
  type: 'boolean',
51
47
  description: {
52
- en: 'Alias for include_closed — include all terminal-status agents in the list',
53
- zh: 'include_closed 的别名 — 列出所有已终止状态的 Agent',
48
+ en: 'Backward-compatible alias for include_closed',
49
+ zh: 'include_closed 的兼容别名',
54
50
  },
55
51
  },
56
52
  },
@@ -61,51 +57,57 @@ stale/stalled 诊断、result 尾部和消息数量。将此作为异步子 Agen
61
57
  duplicateCallPolicy: () => 'allow',
62
58
  async execute(input, ctx) {
63
59
  const includeTerminal = Boolean(input?.include_closed || input?.include_terminal);
64
- const agents = getAgentRegistry();
65
60
  const now = Date.now();
61
+ const agents = [];
66
62
 
67
- const agentList = [];
68
- for (const [id, agent] of agents) {
63
+ for (const [id, agent] of getAgentRegistry()) {
69
64
  if (!agentBelongsToCaller(agent, ctx)) continue;
70
65
  if (!includeTerminal && isTerminalAgentStatus(agent.status)) continue;
71
- const liveness = diagnoseAgentLiveness(agent, { now });
72
- const resultText = (typeof agent.result === 'string' && agent.result)
73
- ? agent.result
74
- : (agent.lastResult || '');
75
- agentList.push({
66
+ const live = diagnoseAgentLiveness(agent, { now });
67
+ const execution = live.execution;
68
+ agents.push({
76
69
  id,
77
70
  name: agent.name,
78
71
  status: agent.status,
79
72
  task: typeof agent.task === 'string' ? agent.task.slice(0, 200) : null,
80
73
  outputFile: agent.outputFile || null,
81
- liveness,
82
- lastEventAt: liveness.lastEventAt,
83
- msSinceLastEvent: liveness.msSinceLastEvent,
84
- lastEventType: liveness.lastEventType,
85
- stale: liveness.stale,
86
- stalled: liveness.stalled,
87
- diagnostic: liveness.diagnostic,
74
+ activity: {
75
+ lastEventAt: live.lastEventAt,
76
+ msSinceLastEvent: live.msSinceLastEvent,
77
+ lastEventType: live.lastEventType,
78
+ recentTools: live.recentTools,
79
+ outputChars: live.outputChars,
80
+ },
81
+ usage: {
82
+ toolExecutions: execution?.toolCalls || 0,
83
+ llmRequests: execution?.llmCalls || agent.usage?.llmCalls || 0,
84
+ providerTokens: live.usageTokens,
85
+ turns: agent.usage?.turns || 0,
86
+ },
87
+ control: {
88
+ limits: execution?.limits || { ...agent.budget },
89
+ remainingToolCalls: execution?.remainingToolCalls ?? null,
90
+ remainingLlmCalls: execution?.remainingLlmCalls ?? null,
91
+ remainingWallTimeMs: execution?.remainingWallTimeMs ?? null,
92
+ reportingLlmCalls: execution?.reportingLlmCalls || 0,
93
+ allowTools: execution?.allowTools || [...(agent.allowTools || [])],
94
+ controlRevision: execution?.controlRevision || 0,
95
+ },
96
+ stale: live.stale,
97
+ diagnostic: live.diagnostic,
88
98
  error: agent.error || null,
89
99
  hasResult: Boolean(agent.result || agent.lastResult),
90
- resultTail: resultText ? resultText.slice(-1000) : '',
91
- messages: Array.isArray(agent.messages) ? agent.messages.length : 0,
92
- turns: agent.usage?.turns || 0,
93
- createdAt: agent.createdAt,
100
+ next_step: nextStepFor(agent, live),
94
101
  });
95
102
  }
96
103
 
97
- if (agentList.length === 0) {
98
- return JSON.stringify({
99
- agents: [],
104
+ return JSON.stringify({
105
+ agents,
106
+ ...(agents.length === 0 ? {
100
107
  message: includeTerminal
101
108
  ? 'No sub-agents in the registry'
102
109
  : 'No active sub-agents (pass include_closed=true to see terminal ones)',
103
- });
104
- }
105
-
106
- return JSON.stringify({
107
- agents: agentList,
108
- totalCount: agentList.length,
109
- }, null, 2);
110
+ } : {}),
111
+ });
110
112
  },
111
113
  });
@@ -4,11 +4,31 @@
4
4
 
5
5
  import { defineTool } from './types.js';
6
6
 
7
+ /** Model-facing projection only; TaskManager snapshots remain the UI/API source. */
8
+ export function compactTaskSnapshot(task) {
9
+ if (!task || typeof task !== 'object') return null;
10
+ const runtime = task.runtime && typeof task.runtime === 'object' ? task.runtime : {};
11
+ const log = task.log && typeof task.log === 'object' ? task.log : {};
12
+ return {
13
+ id: task.id,
14
+ kind: task.kind,
15
+ title: typeof task.title === 'string' ? task.title.slice(0, 200) : task.title,
16
+ status: task.status,
17
+ resultDelivery: task.resultDelivery,
18
+ updatedAt: task.updatedAt,
19
+ ...(runtime.subAgentId ? { agentId: runtime.subAgentId } : {}),
20
+ ...(log.path ? { logPath: log.path } : {}),
21
+ ...(runtime.cancelRequestedAt ? { cancelPending: true } : {}),
22
+ ...(runtime.cancelEscalatedAt ? { cancelEscalated: true } : {}),
23
+ ...(runtime.cancelEscalationFailed ? { cancelEscalationFailed: true } : {}),
24
+ };
25
+ }
26
+
7
27
  export default defineTool({
8
28
  name: 'ListTasks',
9
29
  description: {
10
- en: 'List currently running Session background tasks.',
11
- zh: '列出当前正在运行的 Session 后台任务。',
30
+ en: 'List currently running Session background tasks as compact status references. Use ReadTaskLog for task output.',
31
+ zh: '以紧凑状态引用列出当前运行的 Session 后台任务;任务输出请用 ReadTaskLog。',
12
32
  },
13
33
  parameters: {
14
34
  type: 'object',
@@ -25,6 +45,14 @@ export default defineTool({
25
45
  async execute(input = {}, ctx = {}) {
26
46
  if (!ctx.taskManager) return JSON.stringify({ error: 'task manager unavailable' });
27
47
  const sessionId = input.sessionId || ctx.sessionId || null;
28
- return JSON.stringify({ tasks: ctx.taskManager.listActiveTasks(sessionId) }, null, 2);
48
+ const tasks = ctx.taskManager.listActiveTasks(sessionId)
49
+ .map(compactTaskSnapshot)
50
+ .filter(Boolean);
51
+ return JSON.stringify({
52
+ tasks,
53
+ next_steps: tasks.length > 0
54
+ ? 'ReadTaskLog reads output by task id. For sub_agent tasks use WaitAgent/CloseAgent with agentId to collect/cancel; for shell tasks use CancelTask only when cancellation is intended. cancelPending is not proof the process has stopped.'
55
+ : 'No active tasks require follow-up.',
56
+ });
29
57
  },
30
58
  });
@@ -119,7 +119,7 @@ function processGroupIsInactive(pid) {
119
119
  *
120
120
  * @param {string} command
121
121
  * @param {string[]} args
122
- * @param {{ cwd?: string, signal?: AbortSignal, timeoutMs?: number, maxBytes?: number, env?: NodeJS.ProcessEnv, preserveCarriageReturns?: boolean, killGraceMs?: number, gracefulTerminationDeadline?: number, terminationDeadline?: number, forceSettleMs?: number, treeKillTimeoutMs?: number, requireExitConfirmation?: boolean, requireProcessGroupExit?: boolean, systemdScope?: { unit: string, systemctlPath: string, env?: NodeJS.ProcessEnv } | null, onSettled?: (() => void) | null, platform?: NodeJS.Platform, spawnProcess?: typeof spawn, spawnProcessSync?: typeof spawnSync }} [options]
122
+ * @param {{ cwd?: string, signal?: AbortSignal, timeoutMs?: number, maxBytes?: number, outputLimitAction?: 'stop'|'head-tail', env?: NodeJS.ProcessEnv, preserveCarriageReturns?: boolean, killGraceMs?: number, gracefulTerminationDeadline?: number, terminationDeadline?: number, forceSettleMs?: number, treeKillTimeoutMs?: number, requireExitConfirmation?: boolean, requireProcessGroupExit?: boolean, systemdScope?: { unit: string, systemctlPath: string, env?: NodeJS.ProcessEnv } | null, onSettled?: (() => void) | null, platform?: NodeJS.Platform, spawnProcess?: typeof spawn, spawnProcessSync?: typeof spawnSync }} [options]
123
123
  * @returns {Promise<{ code: number, stdout: string, stderr: string, truncated: boolean, timedOut: boolean, terminationError?: string }>}
124
124
  */
125
125
  export function runProcess(command, args, options = {}) {
@@ -135,6 +135,7 @@ export function runProcess(command, args, options = {}) {
135
135
  const maxBytes = Number.isFinite(options.maxBytes)
136
136
  ? Math.max(0, options.maxBytes)
137
137
  : DEFAULT_MAX_BYTES;
138
+ const keepOutputTail = options.outputLimitAction === 'head-tail';
138
139
  const killGraceMs = Number.isFinite(options.killGraceMs)
139
140
  ? Math.max(0, options.killGraceMs)
140
141
  : DEFAULT_KILL_GRACE_MS;
@@ -212,6 +213,18 @@ export function runProcess(command, args, options = {}) {
212
213
  };
213
214
  const decode = (chunks, wasTruncated, preserveCarriageReturns = false) => {
214
215
  const decoder = new StringDecoder('utf8');
216
+ if (wasTruncated && keepOutputTail) {
217
+ const buffer = Buffer.concat(chunks);
218
+ const marker = '\n[Process output middle omitted]\n';
219
+ const budget = Math.max(0, maxBytes - Buffer.byteLength(marker));
220
+ const headEnd = Math.floor(budget / 2);
221
+ let tailStart = buffer.length - (budget - headEnd);
222
+ while (tailStart < buffer.length && (buffer[tailStart] & 0xc0) === 0x80) tailStart += 1;
223
+ const head = decoder.write(buffer.subarray(0, headEnd));
224
+ const tail = new StringDecoder('utf8').write(buffer.subarray(tailStart));
225
+ const value = head + marker.slice(0, maxBytes) + tail;
226
+ return preserveCarriageReturns ? value : value.replace(/\r/g, '');
227
+ }
215
228
  let value = decoder.write(Buffer.concat(chunks));
216
229
  if (!wasTruncated) value += decoder.end();
217
230
  return preserveCarriageReturns ? value : value.replace(/\r/g, '');
@@ -357,6 +370,21 @@ export function runProcess(command, args, options = {}) {
357
370
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
358
371
  const current = isStdout ? stdoutBytes : stderrBytes;
359
372
  const remaining = maxBytes - current;
373
+ if (keepOutputTail && (buffer.length > remaining || (isStdout ? stdoutTruncated : stderrTruncated))) {
374
+ const prior = Buffer.concat(target);
375
+ const headSize = Math.floor(maxBytes / 2);
376
+ const tailSize = maxBytes - headSize;
377
+ const head = prior.length >= headSize ? prior.subarray(0, headSize)
378
+ : Buffer.concat([prior, buffer.subarray(0, headSize - prior.length)]);
379
+ const tail = buffer.length >= tailSize ? buffer.subarray(buffer.length - tailSize)
380
+ : Buffer.concat([prior.subarray(Math.max(0, prior.length - (tailSize - buffer.length))), buffer]);
381
+ // Copy slices so a tiny retained tail cannot retain an unbounded chunk.
382
+ target.splice(0, target.length, Buffer.from(head), Buffer.from(tail));
383
+ truncated = true;
384
+ if (isStdout) { stdoutTruncated = true; stdoutBytes = maxBytes; }
385
+ else { stderrTruncated = true; stderrBytes = maxBytes; }
386
+ return;
387
+ }
360
388
  if (remaining <= 0) {
361
389
  truncated = true;
362
390
  if (isStdout) stdoutTruncated = true;
@@ -216,6 +216,14 @@ export function isToolErrorOutput(output) {
216
216
  return parseToolErrorOutput(output) !== null;
217
217
  }
218
218
 
219
+ // Only an explicit, side-effect-free validation envelope qualifies. Runtime
220
+ // failures (network, tests, IO) must never be guessed to be invalid arguments.
221
+ export function toolValidationError(output) {
222
+ const parsed = parseToolErrorOutput(output);
223
+ return parsed?.code === 'invalid_arguments' && parsed.errorEffect === 'none'
224
+ ? parsed.error : null;
225
+ }
226
+
219
227
  export function toolErrorEffect(output) {
220
228
  return parseToolErrorOutput(output)?.errorEffect === 'none' ? 'none' : 'unknown';
221
229
  }
@@ -245,6 +253,16 @@ export function truncateToolResultIfNeeded(output, { toolName, language } = {})
245
253
  }
246
254
  marker = truncateUtf8(marker, TOOL_RESULT_MAX_BYTES);
247
255
  const contentBudget = Math.max(0, TOOL_RESULT_MAX_BYTES - Buffer.byteLength(marker, 'utf8'));
256
+ if (toolName === 'Bash' && contentBudget > 256) {
257
+ const omission = normalizeLanguage(language) === 'zh'
258
+ ? '\n[中间输出省略;以下为末尾]\n' : '\n[Middle omitted; output tail follows]\n';
259
+ const budget = contentBudget - Buffer.byteLength(omission, 'utf8');
260
+ const headBudget = Math.floor(budget / 2);
261
+ const buffer = Buffer.from(text, 'utf8');
262
+ let start = buffer.length - (budget - headBudget);
263
+ while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) start += 1;
264
+ return truncateUtf8(text, headBudget) + omission + buffer.subarray(start).toString('utf8') + marker;
265
+ }
248
266
  return truncateUtf8(text, contentBudget) + marker;
249
267
  }
250
268
 
@@ -59,7 +59,8 @@ export default defineTool({
59
59
  agent.budget = { ...agent.budget, ...input.budget };
60
60
  if (grants) agent.allowTools = grants.tools;
61
61
  agent.controlRevision = (agent.controlRevision || 0) + 1;
62
- if (agent.execution && agent.execution.toolCalls < agent.budget.max_tool_calls * 0.75) agent.execution.warning = null;
62
+ if (agent.execution && (agent.budget.max_tool_calls === undefined
63
+ || agent.execution.toolCalls < agent.budget.max_tool_calls * 0.75)) agent.execution.warning = null;
63
64
  if (!agent.budgetReportStarted) {
64
65
  agent.toolBudgetReason = null;
65
66
  agent.executionBudgetReason = null;