@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.
- package/connection/message-router.js +4 -1
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +107 -86
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/conversation/persist.js +89 -0
- package/yeaft/engine.js +34 -45
- package/yeaft/prompts.js +27 -320
- package/yeaft/sessions/index.js +1 -0
- package/yeaft/sessions/session-crud.js +64 -2
- package/yeaft/sub-agent/execution-control.js +66 -6
- package/yeaft/sub-agent/liveness.js +9 -1
- package/yeaft/sub-agent/runner.js +48 -39
- package/yeaft/sub-agent/spawned-prompt.js +3 -2
- package/yeaft/sub-agent/tool-access.js +138 -0
- package/yeaft/tasks/manager.js +0 -56
- package/yeaft/templates/common-rules.md +2 -2
- package/yeaft/templates/personas/reviewer.md +5 -2
- package/yeaft/tools/activation.js +2 -0
- package/yeaft/tools/agent.js +30 -14
- package/yeaft/tools/file-read.js +4 -5
- package/yeaft/tools/git-read.js +266 -0
- package/yeaft/tools/index.js +4 -0
- package/yeaft/tools/update-agent.js +79 -0
- package/yeaft/web-bridge.js +26 -30
- package/yeaft/templates/tool-guidance.md +0 -11
package/yeaft/tools/agent.js
CHANGED
|
@@ -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
|
-
|
|
112
|
-
|
|
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
|
|
241
|
-
-
|
|
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
|
-
-
|
|
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,
|
package/yeaft/tools/file-read.js
CHANGED
|
@@ -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
|
|
31
|
-
* lines" rule
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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;
|
package/yeaft/tools/index.js
CHANGED
|
@@ -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
|
+
});
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -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
|
-
//
|
|
5702
|
-
//
|
|
5703
|
-
//
|
|
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);
|