@yeaft/webchat-agent 1.0.513 → 1.0.515

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.
@@ -14,8 +14,11 @@
14
14
  * budget?: {
15
15
  * max_tokens?: number,
16
16
  * max_turns?: number,
17
+ * max_tool_calls?: number,
18
+ * max_llm_calls?: number,
17
19
  * wall_time_ms?: number
18
20
  * },
21
+ * allow_tools?: string[], // extra parent tool grants beyond persona defaults
19
22
  * cwd?: string
20
23
  * }
21
24
  */
@@ -24,7 +27,8 @@ import { defineTool } from './types.js';
24
27
  import { randomUUID } from 'crypto';
25
28
  import { getPersona, listPersonaIds } from '../personas.js';
26
29
  import { startSubAgent } from '../sub-agent/runner.js';
27
- import { resolveSubAgentBudget } from '../sub-agent/execution-control.js';
30
+ import { resolveSubAgentBudget, validateBudget } from '../sub-agent/execution-control.js';
31
+ import { validateToolGrants } from '../sub-agent/tool-access.js';
28
32
  import { STATUS, isTerminalAgentStatus } from '../sub-agent/status.js';
29
33
  import { diagnoseAgentLiveness, makeLiveness } from '../sub-agent/liveness.js';
30
34
  import { TASK_RESULT_DELIVERY } from '../tasks/store.js';
@@ -108,15 +112,8 @@ export function validateSpec(input) {
108
112
  return { ok: false, error: 'expected_output must be a JSON schema object' };
109
113
  }
110
114
  if (budget !== undefined) {
111
- if (typeof budget !== 'object' || budget === null) {
112
- return { ok: false, error: 'budget must be an object' };
113
- }
114
- for (const k of ['max_tokens', 'max_turns', 'wall_time_ms', 'max_tool_calls']) {
115
- if (budget[k] !== undefined && (!Number.isFinite(budget[k]) || budget[k] <= 0
116
- || (['max_turns', 'max_tool_calls'].includes(k) && !Number.isSafeInteger(budget[k])))) {
117
- return { ok: false, error: `budget.${k} must be finite and positive (counts must be integers)` };
118
- }
119
- }
115
+ const error = validateBudget(budget);
116
+ if (error) return { ok: false, error };
120
117
  }
121
118
  return {
122
119
  ok: true,
@@ -237,8 +234,10 @@ Pick a preset persona to pre-wire a tool subset and model tier:
237
234
  Guidelines:
238
235
  - Give a clear, focused mission — what "done" looks like
239
236
  - Use expected_output when the return shape matters
240
- - Delegate only a bounded independent result; do simple work directly. Set scope, evidence and stopping conditions in mission.
241
- - Inspect execution counters and partial evidence before extending budgets; do not respawn the same exhausted mission automatically.
237
+ - Delegate one clear result with the workspace/base and completion evidence. Let the child choose its steps; do simple work directly. Split unrelated goals, not individual reads.
238
+ - Usually omit budget: defaults are safety ceilings, not targets. Do not impose a tiny tool limit on a multi-file review. Tool exhaustion reserves one tool-free handoff within the remaining time/token limits; unfinished work stays budget_exceeded.
239
+ - Persona tools are defaults, not task boundaries: reviewer has GitRead; grant Bash or write tools explicitly via allow_tools only when needed. Bash is not a read-only sandbox; isolate concurrent writable tasks.
240
+ - Use UpdateAgent to adjust a live child's time/tool/LLM ceilings or extra grants after inspecting evidence; counters and context are retained. Do not extend stalled work blindly or respawn the same exhausted mission automatically.
242
241
 
243
242
  Async orchestration:
244
243
  1. SpawnAgent — starts the sub-agent as a background task and returns immediately.
@@ -268,8 +267,10 @@ max_tokens 在 provider usage 到达时检查;max_turns 是 query turn 数,
268
267
  使用指南:
269
268
  - 给出清晰聚焦的 mission——"完成"是什么样子
270
269
  - 当返回结构重要时使用 expected_output
271
- - 只委派有界且独立的结果;简单工作直接做。在 mission 中写清范围、证据和停止条件。
272
- - 扩大预算前检查实际执行计数和已有证据;不要自动重启同一个耗尽预算的任务。
270
+ - 一次只委派一个明确结果,提供工作目录/基线和完成证据,让子 Agent 自主选择步骤;简单工作直接做。拆分不相关目标,不要拆成逐个读取任务。
271
+ - 通常省略 budget:默认值是安全上限,不是执行目标。不要给多文件 review 人为设置极小的工具额度。工具耗尽后会在剩余时间/token预算内留一次无工具交付机会,未完成仍返回 budget_exceeded。
272
+ - Persona 是默认工具集,不是任务死边界:reviewer 有 GitRead;按需用 allow_tools 显式授予 Bash/写工具。Bash 并非只读沙箱;并行写任务应隔离 workspace。
273
+ - 检查已有证据后,用 UpdateAgent 原地调整活跃子任务的时间/工具/LLM 上限或额外授权,保留计数与上下文;不要盲目扩额停滞任务,也不要自动重启同一个耗尽任务。
273
274
 
274
275
  异步编排流程:
275
276
  1. SpawnAgent — 启动子 Agent 作为后台任务并立即返回。
@@ -333,6 +334,10 @@ liveness,不要盲目循环。`
333
334
  max_turns: { type: 'number', description: {
334
335
  en: 'Optional turn ceiling; no default limit is applied',
335
336
  zh: '可选 turn 上限;默认不设限制',
337
+ } },
338
+ max_llm_calls: { type: 'integer', minimum: 1, description: {
339
+ en: 'Optional actual Engine provider-dispatch ceiling (including retries); distinct from query turns. One extra tool-free reporting request is reserved and separately counted.',
340
+ zh: '可选实际 Engine 模型请求上限(含重试),不同于 query turn;另保留并单独统计一次无工具报告请求。',
336
341
  } },
337
342
  max_tool_calls: { type: 'integer', minimum: 1, description: {
338
343
  en: 'Actual tool execution ceiling; default 64, or 128 for implementer. Includes parallel and discovered tools.',
@@ -348,6 +353,13 @@ liveness,不要盲目循环。`
348
353
  zh: '覆盖默认工具/时间安全上限;token/turn 限制可选。截止时返回 { status: "budget_exceeded", partial_output, reason },不代表任务成功。',
349
354
  },
350
355
  },
356
+ allow_tools: {
357
+ type: 'array', items: { type: 'string' }, maxItems: 32,
358
+ description: {
359
+ en: 'Explicit extra parent tools beyond persona defaults (e.g. Bash, FileEdit). Bash permits arbitrary shell/writes, not a read-only sandbox. Grant only necessary tools and isolate writable workspaces; child orchestration stays forbidden.',
360
+ zh: '在 persona 默认工具之外显式授予父级工具(如 Bash、FileEdit)。Bash 可执行任意 Shell/写入,并非只读沙箱。仅授予必要工具并隔离写入 workspace;子级编排仍禁止。',
361
+ },
362
+ },
351
363
  cwd: {
352
364
  type: 'string',
353
365
  description: {
@@ -379,6 +391,8 @@ liveness,不要盲目循环。`
379
391
  return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: validation.error });
380
392
  }
381
393
  const spec = validation.spec;
394
+ const grants = validateToolGrants(input.allow_tools === undefined ? [] : input.allow_tools, ctx?.parentEngineDeps?.parentToolRegistry);
395
+ if (!grants.ok) return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: grants.error });
382
396
  const { name, cwd } = input;
383
397
  const callerScope = getCallerAgentScope(ctx);
384
398
 
@@ -411,6 +425,7 @@ liveness,不要盲目循环。`
411
425
  // Capture at the tool boundary, before fire-and-forget startup can yield.
412
426
  parentEffortDecision: captureParentEffortDecision(ctx),
413
427
  budget: spec.budget,
428
+ allowTools: grants.tools,
414
429
  cwd: cwd || ctx?.cwd || process.cwd(),
415
430
  status: STATUS.CREATED,
416
431
  messages: [],
@@ -499,6 +514,7 @@ liveness,不要盲目循环。`
499
514
  name,
500
515
  persona: spec.persona || null,
501
516
  budget: spec.budget || null,
517
+ allow_tools: agent.allowTools,
502
518
  status: agent.status,
503
519
  outputFile: agent.outputFile || null,
504
520
  taskId: agent.taskId || null,
@@ -27,11 +27,10 @@ const BINARY_EXTS = new Set([
27
27
  /** Max file size to read (10 MB). */
28
28
  const MAX_FILE_SIZE = 10 * 1024 * 1024;
29
29
 
30
- /** Default number of lines to read. Aligned with the "large file = >3000
31
- * lines" rule in templates/{base,common-rules,tool-guidance}.md so a
32
- * ≤3000-line file is returned in one call (no silent truncation that
33
- * would trigger a follow-up `offset:3000` call — that's exactly the
34
- * round-trip this tool's prompt guidance promises to avoid). */
30
+ /** Default number of lines to read. Aligned with the FileRead tool schema's
31
+ * "large file = >3000 lines" rule so a ≤3000-line file is returned in one
32
+ * call (no silent truncation that would trigger a follow-up `offset:3000`
33
+ * call — exactly the round-trip the tool guidance avoids). */
35
34
  const DEFAULT_LIMIT = 3000;
36
35
 
37
36
  /** Keep raw output close to the model-facing 32 KiB budget while leaving
@@ -0,0 +1,266 @@
1
+ import { resolve } from 'node:path';
2
+ import { defineTool } from './types.js';
3
+ import { runProcess } from './process-runner.js';
4
+
5
+ const MAX_RESULT_BYTES = 30 * 1024;
6
+ const MAX_CAPTURE_BYTES = 64 * 1024;
7
+ const MAX_PATHS = 50;
8
+ const MAX_VALUE_LENGTH = 4096;
9
+ const DEFAULT_LOG_LIMIT = 20;
10
+ const MAX_LOG_LIMIT = 50;
11
+ const TIMEOUT_MS = 30_000;
12
+ const RUN_PROCESS_OVERRIDE = Symbol('runProcessOverride');
13
+
14
+ const COMMON_ARGS = Object.freeze([
15
+ '--no-pager',
16
+ '--no-optional-locks',
17
+ '--literal-pathspecs',
18
+ '--no-replace-objects',
19
+ '-c', 'color.ui=false',
20
+ '-c', 'core.fsmonitor=false',
21
+ '-c', 'log.showSignature=false',
22
+ '-c', 'submodule.recurse=false',
23
+ '-c', 'diff.submodule=short',
24
+ '-c', 'protocol.allow=never',
25
+ ]);
26
+
27
+ // Worktree status/diff may invoke clean/process filters even with textconv and
28
+ // external diff disabled. Discover their keys, never values, and override them
29
+ // for this process only. Incomplete discovery fails closed.
30
+ async function filterOverrides(run, options) {
31
+ const result = await run('git', [
32
+ ...COMMON_ARGS, 'config', '--null', '--name-only', '--get-regexp',
33
+ '^filter\\..*\\.(clean|smudge|process|required)$',
34
+ ], options);
35
+ if (result.truncated || result.timedOut || (result.code !== 0 && result.code !== 1)) {
36
+ throw new Error('Cannot safely inspect Git content filters');
37
+ }
38
+ if (result.code === 1) return [];
39
+ const keys = [...new Set(result.stdout.split('\0').filter(Boolean))];
40
+ if (keys.length > 200) throw new Error('Too many Git content filters for a bounded read');
41
+ return keys.flatMap(key => {
42
+ if (!/^filter\..*\.(clean|smudge|process|required)$/.test(key) || /[=\r\n]/.test(key)) {
43
+ throw new Error('Unsupported Git filter key; refusing an unsafe read');
44
+ }
45
+ return ['-c', `${key}=${key.endsWith('.required') ? 'false' : ''}`];
46
+ });
47
+ }
48
+
49
+ function errorOutput(message) {
50
+ return JSON.stringify({ error: message });
51
+ }
52
+
53
+ function validateValue(value, name) {
54
+ if (typeof value !== 'string' || !value) return `${name} must be a non-empty string`;
55
+ if (value.length > MAX_VALUE_LENGTH) return `${name} must be at most ${MAX_VALUE_LENGTH} characters`;
56
+ if (value.startsWith('-')) return `${name} must not start with "-"`;
57
+ if (/\0|[\r\n]/u.test(value)) return `${name} must not contain NUL or newlines`;
58
+ return null;
59
+ }
60
+
61
+ function validatePaths(paths) {
62
+ if (paths === undefined) return null;
63
+ if (!Array.isArray(paths)) return 'paths must be an array of strings';
64
+ if (paths.length > MAX_PATHS) return `paths must contain at most ${MAX_PATHS} entries`;
65
+ for (let index = 0; index < paths.length; index += 1) {
66
+ const error = validateValue(paths[index], `paths[${index}]`);
67
+ if (error) return error;
68
+ if (paths[index].includes(':(attr:')) return `paths[${index}] must not use pathspec attributes`;
69
+ }
70
+ return null;
71
+ }
72
+
73
+ function unexpectedInput(input, allowed) {
74
+ const unexpected = Object.keys(input).filter(key => !allowed.has(key));
75
+ return unexpected.length > 0 ? `Unexpected parameter(s) for ${input.operation}: ${unexpected.join(', ')}` : null;
76
+ }
77
+
78
+ export function buildGitReadArgs(input) {
79
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
80
+ return { error: 'input must be an object' };
81
+ }
82
+
83
+ const { operation } = input;
84
+ if (!['status', 'diff', 'show', 'log'].includes(operation)) {
85
+ return { error: 'operation must be one of: status, diff, show, log' };
86
+ }
87
+
88
+ if (operation === 'status') {
89
+ const error = unexpectedInput(input, new Set(['operation']));
90
+ if (error) return { error };
91
+ return { args: [...COMMON_ARGS, 'status', '--short', '--branch', '--untracked-files=normal', '--ignore-submodules=all'] };
92
+ }
93
+
94
+ if (operation === 'diff') {
95
+ const error = unexpectedInput(input, new Set(['operation', 'base', 'head', 'paths']))
96
+ || validatePaths(input.paths)
97
+ || (input.base !== undefined ? validateValue(input.base, 'base') : null)
98
+ || (input.head !== undefined ? validateValue(input.head, 'head') : null);
99
+ if (error) return { error };
100
+ if (input.head !== undefined && input.base === undefined) {
101
+ return { error: 'head requires base' };
102
+ }
103
+ const revision = input.base === undefined
104
+ ? 'HEAD'
105
+ : `${input.base}...${input.head || 'HEAD'}`;
106
+ return {
107
+ args: [
108
+ ...COMMON_ARGS,
109
+ 'diff', '--no-ext-diff', '--no-textconv', '--no-color', '--ignore-submodules=dirty',
110
+ revision, '--', ...(input.paths || []),
111
+ ],
112
+ };
113
+ }
114
+
115
+ if (operation === 'show') {
116
+ const error = unexpectedInput(input, new Set(['operation', 'revision', 'paths']))
117
+ || validatePaths(input.paths)
118
+ || (input.revision !== undefined ? validateValue(input.revision, 'revision') : null);
119
+ if (error) return { error };
120
+ return {
121
+ args: [
122
+ ...COMMON_ARGS,
123
+ 'show', '--no-ext-diff', '--no-textconv', '--no-color', '--format=fuller',
124
+ input.revision || 'HEAD', '--', ...(input.paths || []),
125
+ ],
126
+ };
127
+ }
128
+
129
+ const error = unexpectedInput(input, new Set(['operation', 'revision', 'limit']))
130
+ || (input.revision !== undefined ? validateValue(input.revision, 'revision') : null);
131
+ if (error) return { error };
132
+ const limit = input.limit === undefined ? DEFAULT_LOG_LIMIT : input.limit;
133
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LOG_LIMIT) {
134
+ return { error: `limit must be an integer between 1 and ${MAX_LOG_LIMIT}` };
135
+ }
136
+ return {
137
+ args: [
138
+ ...COMMON_ARGS,
139
+ 'log', '--no-color', `--max-count=${limit}`, '--date=iso-strict',
140
+ '--format=%H%x09%ad%x09%an%x09%s', input.revision || 'HEAD', '--',
141
+ ],
142
+ };
143
+ }
144
+
145
+ function takeUtf8(text, maxBytes) {
146
+ const buffer = Buffer.from(String(text), 'utf8');
147
+ if (buffer.length <= maxBytes) return String(text);
148
+ let end = maxBytes;
149
+ while (end > 0 && (buffer[end] & 0xc0) === 0x80) end -= 1;
150
+ return buffer.subarray(0, end).toString('utf8');
151
+ }
152
+
153
+ export function formatGitReadResult(operation, result) {
154
+ const timedOut = Boolean(result.timedOut);
155
+ const runnerTruncated = Boolean(result.truncated);
156
+ const sections = [];
157
+ if (result.stdout) sections.push(`STDOUT:\n${result.stdout}`);
158
+ if (result.stderr) sections.push(`STDERR:\n${result.stderr}`);
159
+ const body = sections.join('\n');
160
+ const baseHeader = truncated => [
161
+ `operation: ${operation}`,
162
+ `exitCode: ${runnerTruncated ? 'not observed (output limit reached)' : result.code}`,
163
+ `timedOut: ${timedOut}`,
164
+ `truncated: ${truncated}`,
165
+ ].join('\n');
166
+ const initial = `${baseHeader(runnerTruncated)}\n\n${body || '(no output)'}`;
167
+ if (!runnerTruncated && Buffer.byteLength(initial, 'utf8') <= MAX_RESULT_BYTES) return initial;
168
+
169
+ const marker = '\n\n[Output truncated by GitRead; narrow the revision or paths.]';
170
+ const header = `${baseHeader(true)}\n\n`;
171
+ const bodyBudget = Math.max(
172
+ 0,
173
+ MAX_RESULT_BYTES - Buffer.byteLength(header, 'utf8') - Buffer.byteLength(marker, 'utf8'),
174
+ );
175
+ return header + takeUtf8(body || '(no output)', bodyBudget) + marker;
176
+ }
177
+
178
+ const gitReadTool = defineTool({
179
+ name: 'GitRead',
180
+ description: {
181
+ en: `Read bounded local Git evidence without a shell or network access.
182
+
183
+ Supported operations are intentionally limited:
184
+ - status: compact branch and working-tree status.
185
+ - diff: tracked changes against HEAD by default, or an explicit base...head range; optional paths narrow the result.
186
+ - show: one commit (HEAD by default), optionally narrowed by paths.
187
+ - log: a compact bounded commit list (20 entries by default, maximum 50).
188
+
189
+ GitRead never fetches, writes Git state, or creates worktrees. It disables pagers, external diff, textconv, content filters, optional locks, fsmonitor, and submodule traversal. Filter-normalized files (such as LFS) show raw worktree bytes; submodule status needs separate inspection. Revisions and paths beginning with "-" are rejected. Output reports whether it was truncated.`,
190
+ zh: `有界读取本地 Git 证据,不使用 shell,也不访问网络。
191
+
192
+ 操作范围刻意限制为:
193
+ - status:紧凑显示分支和工作区状态。
194
+ - diff:默认显示相对 HEAD 的已跟踪改动,也可指定 base...head;可用 paths 缩小范围。
195
+ - show:显示一个提交(默认 HEAD),可用 paths 缩小范围。
196
+ - log:紧凑且有界的提交列表(默认 20 条,最多 50 条)。
197
+
198
+ GitRead 不 fetch、不写 Git 状态、不创建 worktree。它禁用 pager、external diff、textconv、内容 filter、optional locks、fsmonitor 和子模块遍历。LFS 等 filter 文件显示原始工作区字节,子模块状态需单独检查。拒绝以 "-" 开头的 revision 与路径;结果明确标识是否截断。`,
199
+ },
200
+ parameters: {
201
+ type: 'object',
202
+ additionalProperties: false,
203
+ properties: {
204
+ 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)' },
208
+ paths: {
209
+ type: 'array',
210
+ maxItems: MAX_PATHS,
211
+ items: { type: 'string', minLength: 1, maxLength: MAX_VALUE_LENGTH },
212
+ description: 'Optional repository-relative paths for diff or show',
213
+ },
214
+ limit: { type: 'integer', minimum: 1, maximum: MAX_LOG_LIMIT, description: 'Maximum log entries' },
215
+ },
216
+ required: ['operation'],
217
+ },
218
+ timeoutMs: 0,
219
+ isConcurrencySafe: () => true,
220
+ isReadOnly: () => true,
221
+ async execute(input, ctx) {
222
+ const built = buildGitReadArgs(input);
223
+ if (built.error) return errorOutput(built.error);
224
+ const cwd = resolve(ctx?.cwd || process.cwd());
225
+ try {
226
+ const run = ctx?.[RUN_PROCESS_OVERRIDE] || runProcess;
227
+ const startedAt = Date.now();
228
+ const options = {
229
+ cwd,
230
+ signal: ctx?.signal,
231
+ timeoutMs: TIMEOUT_MS,
232
+ maxBytes: MAX_CAPTURE_BYTES,
233
+ env: {
234
+ ...process.env,
235
+ GIT_PAGER: 'cat',
236
+ PAGER: 'cat',
237
+ GIT_EXTERNAL_DIFF: '',
238
+ GIT_NO_LAZY_FETCH: '1',
239
+ GIT_OPTIONAL_LOCKS: '0',
240
+ GIT_TERMINAL_PROMPT: '0',
241
+ NO_COLOR: '1',
242
+ },
243
+ };
244
+ const overrides = await filterOverrides(run, options);
245
+ const result = await run('git', [...overrides, ...built.args], {
246
+ ...options, timeoutMs: Math.max(1, TIMEOUT_MS - (Date.now() - startedAt)),
247
+ });
248
+ return formatGitReadResult(input.operation, result);
249
+ } catch (error) {
250
+ if (error?.name === 'AbortError') throw error;
251
+ return errorOutput(`GitRead failed: ${error?.message || String(error)}`);
252
+ }
253
+ },
254
+ });
255
+
256
+ export function createGitReadTool({ runProcessImpl = runProcess } = {}) {
257
+ return {
258
+ ...gitReadTool,
259
+ execute(input, ctx) {
260
+ return gitReadTool.execute(input, { ...ctx, [RUN_PROCESS_OVERRIDE]: runProcessImpl });
261
+ },
262
+ };
263
+ }
264
+
265
+ export { MAX_RESULT_BYTES };
266
+ export default gitReadTool;
@@ -33,6 +33,7 @@ import discoverTools from './discover-tools.js';
33
33
 
34
34
  // --- P0 File tools ---
35
35
  import bash from './bash.js';
36
+ import gitRead from './git-read.js';
36
37
  import fileRead from './file-read.js';
37
38
  import fileWrite from './file-write.js';
38
39
  import fileEdit from './file-edit.js';
@@ -47,6 +48,7 @@ import cancelTask from './cancel-task.js';
47
48
 
48
49
  // --- P1 Agent tools ---
49
50
  import agentTool from './agent.js';
51
+ import updateAgent from './update-agent.js';
50
52
  import sendMessage from './send-message.js';
51
53
  import waitAgent from './wait-agent.js';
52
54
  import closeAgent from './close-agent.js';
@@ -101,6 +103,7 @@ export const allTools = [
101
103
 
102
104
  // P0 File
103
105
  bash,
106
+ gitRead,
104
107
  fileRead,
105
108
  fileWrite,
106
109
  fileEdit,
@@ -115,6 +118,7 @@ export const allTools = [
115
118
 
116
119
  // P1 Agent
117
120
  agentTool,
121
+ updateAgent,
118
122
  sendMessage,
119
123
  waitAgent,
120
124
  closeAgent,
@@ -0,0 +1,79 @@
1
+ /** Parent-owned, in-place child controls; never resumes a terminal or reporting child. */
2
+ import { defineTool } from './types.js';
3
+ import { getAgentRegistry, agentBelongsToCaller } from './agent.js';
4
+ import { isTerminalAgentStatus } from '../sub-agent/status.js';
5
+ import { validateBudget } from '../sub-agent/execution-control.js';
6
+ import { validateToolGrants } from '../sub-agent/tool-access.js';
7
+ import { diagnoseAgentLiveness } from '../sub-agent/liveness.js';
8
+
9
+ export default defineTool({
10
+ name: 'UpdateAgent',
11
+ description: {
12
+ en: 'Adjust a live child in place after inspecting its progress: absolute lifetime time/tool/LLM budgets and explicit extra tool grants. Does not queue a prompt, reset usage, or revive a terminal/reporting child. Give evidence and the remaining task in reason; do not extend stalled or repeating work blindly. Bash permits arbitrary shell and writes, not a read-only sandbox; grant only necessary parent tools and isolate writable workspaces. Already dispatched work is not undone by revocation.',
13
+ zh: '检查进展后原地调整活跃子 Agent:累计时间/工具/LLM 上限及额外工具授权。不排队提示、不清零用量、不复活终止或收尾中的任务。reason 说明已有证据和剩余工作,勿盲目给停滞/重复任务扩额。Bash 可执行任意 Shell 和写入,并非只读沙箱;只授予必要的父级工具,写任务隔离 workspace。撤销不撤回已执行操作。',
14
+ },
15
+ parameters: {
16
+ type: 'object',
17
+ properties: {
18
+ agent_id: { type: 'string' },
19
+ reason: { type: 'string', description: { en: 'Observed progress, remaining work and why this adjustment is necessary', zh: '实际进展、剩余工作及调整原因' } },
20
+ budget: {
21
+ type: 'object',
22
+ properties: {
23
+ max_tokens: { type: 'number', minimum: 1 },
24
+ max_turns: { type: 'integer', minimum: 1 },
25
+ max_tool_calls: { type: 'integer', minimum: 1 },
26
+ max_llm_calls: { type: 'integer', minimum: 1 },
27
+ wall_time_ms: { type: 'integer', minimum: 1 },
28
+ },
29
+ description: { en: 'Partial absolute lifetime ceilings, not added quota. Unspecified limits stay unchanged. LLM cap excludes one reserved reporting request; time is measured from original spawn.', zh: '部分累计上限,不是增加的额度;未指定项不变。LLM 上限之外保留一次报告请求;时间从最初启动计算。' },
30
+ },
31
+ allow_tools: {
32
+ type: 'array', items: { type: 'string' }, maxItems: 32,
33
+ description: { en: 'Replace extra persona grants with these canonical parent tool names (e.g. Bash, FileEdit); [] revokes extras, omission leaves grants unchanged', zh: '替换 persona 额外授权(如 Bash、FileEdit);[] 撤销额外授权,省略则不变' },
34
+ },
35
+ },
36
+ required: ['agent_id', 'reason'],
37
+ },
38
+ isReadOnly: () => false,
39
+ isConcurrencySafe: () => false,
40
+ mayMutateWorkspaceAfterReturn: () => true,
41
+ async execute(input, ctx) {
42
+ const fail = error => JSON.stringify({ error, next_steps: 'Inspect the current agent state and correct the request; do not respawn or repeat blindly.' });
43
+ const agent = getAgentRegistry().get(input.agent_id);
44
+ if (!agent || !agentBelongsToCaller(agent, ctx)) return fail(`Agent not found: ${input.agent_id}`);
45
+ if (isTerminalAgentStatus(agent.status) || agent.budgetReportStarted || agent.budgetStopReason
46
+ || agent.abortController?.signal.aborted) return fail('Agent is terminal, stopping or already reporting; it cannot be extended');
47
+ if (typeof input.reason !== 'string' || !input.reason.trim() || input.reason.length > 2000) return fail('reason must contain 1..2000 characters of evidence and remaining work');
48
+ if (input.budget === undefined && input.allow_tools === undefined) return fail('budget or allow_tools is required');
49
+ if (input.budget !== undefined) {
50
+ const error = validateBudget(input.budget);
51
+ if (error) return fail(error);
52
+ }
53
+ const parentRegistry = ctx?.parentEngineDeps?.parentToolRegistry;
54
+ const grants = input.allow_tools === undefined ? null : validateToolGrants(input.allow_tools, parentRegistry);
55
+ if (grants && !grants.ok) return fail(grants.error);
56
+ const previousBudget = { ...agent.budget };
57
+ const previousTools = [...(agent.allowTools || [])];
58
+ // All validation precedes mutation. Original usage/deadline origin are retained.
59
+ agent.budget = { ...agent.budget, ...input.budget };
60
+ if (grants) agent.allowTools = grants.tools;
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;
63
+ if (!agent.budgetReportStarted) {
64
+ agent.toolBudgetReason = null;
65
+ agent.executionBudgetReason = null;
66
+ }
67
+ agent.refreshToolPolicy?.();
68
+ agent.rearmWallTimeWatchdog?.();
69
+ const event = { type: 'sub_agent_control_updated', at: Date.now(), reason: input.reason.trim(),
70
+ previousBudget, budget: { ...agent.budget }, previousTools, allowTools: [...(agent.allowTools || [])] };
71
+ agent.diagnostics ||= [];
72
+ agent.diagnostics.push(event);
73
+ if (agent.diagnostics.length > 100) agent.diagnostics.shift();
74
+ try { agent.outputLog?.write(event); } catch { /* diagnostics must not fail the applied update */ }
75
+ return JSON.stringify({ success: true, agentId: agent.id, status: agent.status,
76
+ budget: agent.budget, allow_tools: agent.allowTools || [], liveness: diagnoseAgentLiveness(agent),
77
+ next_steps: 'Adjustment applied without restarting work. Continue the parent task; use PromptAgent only if new guidance is needed, then collect its reply. Revocation does not cancel already dispatched work.' });
78
+ },
79
+ });
@@ -86,6 +86,7 @@ import {
86
86
  import {
87
87
  SessionCrudError,
88
88
  createSessionFromSpec,
89
+ copySession,
89
90
  renameSession,
90
91
  updateSessionAnnouncement,
91
92
  archiveSession,
@@ -759,25 +760,6 @@ function legacyProjectContext(yeaftDir, sessionId) {
759
760
  }, sessionId);
760
761
  }
761
762
 
762
- function buildProjectSharedBlock(projectContext, summaries = '') {
763
- const context = normalizeProjectContext(projectContext, null);
764
- const body = typeof summaries === 'string' ? summaries.trim() : '';
765
- if (!context?.projectId && !body) return '';
766
- const lines = ['[Project Shared Context]'];
767
- if (context?.projectId) {
768
- const label = context.projectName
769
- ? `${context.projectName} (${context.projectId})`
770
- : context.projectId;
771
- lines.push(`Project: ${label}`);
772
- lines.push('Sharing boundary: sibling Sessions in this Project on this Agent only.');
773
- } else {
774
- lines.push('Sharing boundary: sibling Sessions in the same Project on this Agent only.');
775
- }
776
- lines.push('Read-only memory summaries preserve each source Session identity.');
777
- if (body) lines.push('', body);
778
- return lines.join('\n');
779
- }
780
-
781
763
  function vpKey(sessionId, vpId) {
782
764
  return `${sessionId}::${vpId}`;
783
765
  }
@@ -3699,6 +3681,28 @@ export function handleYeaftCreateSession(msg) {
3699
3681
  }
3700
3682
  }
3701
3683
 
3684
+ export function handleYeaftCopySession(msg) {
3685
+ const requestId = msg && msg.requestId;
3686
+ const sessionId = msg && (msg.sessionId || msg.groupId);
3687
+ try {
3688
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
3689
+ const source = decorateSessionsWithRuntimeState(snapshotSessions(yeaftDir))
3690
+ .find(session => session?.id === sessionId);
3691
+ if (!source) throw new SessionCrudError('not_found', sessionId);
3692
+ if (source.running) throw new SessionCrudError('session_running', sessionId, 'Cannot copy a running Session');
3693
+ const session = copySession(yeaftDir, sessionId, {
3694
+ ...configuredVpPaths(),
3695
+ name: msg && msg.name,
3696
+ });
3697
+ recordAgentSessionCreated();
3698
+ session.config = loadSessionConfig(yeaftDir, session.id);
3699
+ sendSessionCrudResult({ op: 'copy', requestId, ok: true, session });
3700
+ sendSessionSnapshotBroadcast();
3701
+ } catch (err) {
3702
+ sendSessionCrudResult({ op: 'copy', requestId, ok: false, error: sessionErrorPayload(err) });
3703
+ }
3704
+ }
3705
+
3702
3706
  /**
3703
3707
  * `yeaft_scan_workdir_sessions` — compatibility endpoint for the retired
3704
3708
  * workdir Session scan flow. Session data now lives under the user-level
@@ -5698,16 +5702,9 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
5698
5702
  ? `${projectContext.projectName} (${projectContext.projectId})`
5699
5703
  : (projectContext?.projectId || '');
5700
5704
  queryOpts.projectInstruction = projectContext?.projectInstruction || '';
5701
- // Related Session summaries now enter through Engine's single AMS
5702
- // memory outlet. Keep this announcement limited to Project identity and
5703
- // sharing boundaries so parent VP prompts do not duplicate the same prose
5704
- // that sub-agents receive through memory.
5705
- const sharedBlock = buildProjectSharedBlock(projectContext);
5706
- if (sharedBlock) {
5707
- queryOpts.sessionAnnouncement = queryOpts.sessionAnnouncement
5708
- ? `${queryOpts.sessionAnnouncement}\n\n${sharedBlock}`
5709
- : sharedBlock;
5710
- }
5705
+ // Project sibling identity remains available to Engine memory recall via
5706
+ // projectSessionIds. Do not mirror internal sharing metadata into the
5707
+ // user-authored Session announcement shown to the model.
5711
5708
  }
5712
5709
  let turnSessionMeta = null;
5713
5710
  try { turnSessionMeta = sessionCoordinator?.group?.getMeta?.() || null; } catch { turnSessionMeta = null; }
@@ -8215,7 +8212,6 @@ export function handleYeaftMcpReload(msg = {}) {
8215
8212
  export const __testHooks = {
8216
8213
  loadProjects,
8217
8214
  sharedProjectContext,
8218
- buildProjectSharedBlock,
8219
8215
  normalizeProjectContext,
8220
8216
  handleProjectContextSyncForTest(msg) {
8221
8217
  handleYeaftProjectContextSync(msg);
@@ -1,11 +0,0 @@
1
- <!-- lang:en -->
2
-
3
- ## Active Tool Guidance
4
-
5
- {{guidance}}
6
-
7
- <!-- lang:zh -->
8
-
9
- ## 当前工具指引
10
-
11
- {{guidance}}