@yeaft/webchat-agent 1.0.494 → 1.0.496
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/local-runtime/server/handlers/agent-file-terminal.js +2 -1
- package/local-runtime/server/handlers/client-workbench.js +20 -4
- package/local-runtime/server/workbench-route.js +7 -2
- package/local-runtime/version.json +1 -1
- package/package.json +1 -1
- package/yeaft/sub-agent/execution-control.js +90 -0
- package/yeaft/sub-agent/liveness.js +7 -0
- package/yeaft/sub-agent/runner.js +65 -13
- package/yeaft/sub-agent/spawned-prompt.js +20 -4
- package/yeaft/tools/agent.js +27 -16
|
@@ -185,7 +185,8 @@ async function handleOneShotResponse(agentId, agent, msg, routeKey) {
|
|
|
185
185
|
userId: pending.userId,
|
|
186
186
|
role: pending.role,
|
|
187
187
|
});
|
|
188
|
-
if (
|
|
188
|
+
if (currentGeneration === null) return;
|
|
189
|
+
if (currentGeneration && currentGeneration !== pending.workspaceGeneration) return;
|
|
189
190
|
const projected = msg.type === 'file_content' && msg.binary
|
|
190
191
|
? cacheBinaryPreview(msg)
|
|
191
192
|
: msg;
|
|
@@ -37,8 +37,20 @@ function isYeaftVirtualConversation(conversationId) {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
async function denyWorkbenchRoute(client, msg) {
|
|
40
|
+
const error = 'Invalid Workbench Session route';
|
|
40
41
|
console.warn(`[Security] Invalid Workbench route for ${msg?.type || 'unknown'}`);
|
|
41
|
-
|
|
42
|
+
const response = workbenchFailureResponse({
|
|
43
|
+
agentId: msg?.agentId || client.currentAgent,
|
|
44
|
+
msg,
|
|
45
|
+
resolved: {
|
|
46
|
+
conversationId: msg?.conversationId,
|
|
47
|
+
routeKey: msg?.workbenchRouteKey,
|
|
48
|
+
workspaceGeneration: msg?.workbenchWorkspaceGeneration,
|
|
49
|
+
},
|
|
50
|
+
error,
|
|
51
|
+
});
|
|
52
|
+
if (response) await sendToWebClient(client, response);
|
|
53
|
+
await sendToWebClient(client, { type: 'error', message: error });
|
|
42
54
|
}
|
|
43
55
|
|
|
44
56
|
const AGENT_DIRECTORY_PICKER_CONVERSATION = '_workdir_picker';
|
|
@@ -93,10 +105,9 @@ const FILE_OPERATIONS = Object.freeze({
|
|
|
93
105
|
upload_to_dir: 'upload',
|
|
94
106
|
});
|
|
95
107
|
|
|
96
|
-
function
|
|
108
|
+
function workbenchFailureResponse({ agentId, msg, resolved, error }) {
|
|
97
109
|
const type = TIMEOUT_RESPONSE_TYPES[msg.type];
|
|
98
110
|
if (!type) return null;
|
|
99
|
-
const error = 'Workbench request timed out';
|
|
100
111
|
const response = {
|
|
101
112
|
type,
|
|
102
113
|
agentId,
|
|
@@ -189,7 +200,12 @@ function correlateWorkbenchRequest({ agentId, clientId, client, msg, resolved, c
|
|
|
189
200
|
allowLegacyCorrelation: !supportsRequestCorrelation,
|
|
190
201
|
onTimeout: async () => {
|
|
191
202
|
if (!client.authenticated || client.userId !== registration.userId) return;
|
|
192
|
-
const response =
|
|
203
|
+
const response = workbenchFailureResponse({
|
|
204
|
+
agentId,
|
|
205
|
+
msg,
|
|
206
|
+
resolved,
|
|
207
|
+
error: 'Workbench request timed out',
|
|
208
|
+
});
|
|
193
209
|
if (response) await sendToWebClient(client, response);
|
|
194
210
|
},
|
|
195
211
|
};
|
|
@@ -120,14 +120,19 @@ function resolveChatRow(client, route) {
|
|
|
120
120
|
*
|
|
121
121
|
* `legacy: true` preserves old clients that predate route-scoped Workbench.
|
|
122
122
|
*/
|
|
123
|
+
/**
|
|
124
|
+
* Return null when the route is no longer valid, an empty string when the
|
|
125
|
+
* Session is valid but has no Server-owned cwd, or its canonical generation.
|
|
126
|
+
*/
|
|
123
127
|
export function currentWorkbenchWorkspaceGeneration({ route, userId, role }) {
|
|
124
|
-
if (!route || !userId) return
|
|
128
|
+
if (!route || !userId) return null;
|
|
125
129
|
const client = { userId, role };
|
|
126
130
|
const row = route.runtimeProvider === 'yeaft'
|
|
127
131
|
? resolveYeaftRow(client, route)
|
|
128
132
|
: resolveChatRow(client, route);
|
|
129
|
-
if (!row || row.isArchived) return
|
|
133
|
+
if (!row || row.isArchived) return null;
|
|
130
134
|
const workDir = clean(route.runtimeProvider === 'yeaft' ? row.workDir : row.work_dir, 4096);
|
|
135
|
+
if (!workDir) return '';
|
|
131
136
|
return workbenchWorkspaceGeneration(workbenchRouteKey(route), workDir);
|
|
132
137
|
}
|
|
133
138
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.0.
|
|
1
|
+
{"version":"1.0.496"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** Sub-agent-local execution policy. No Session config or parent registry is mutated. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { ToolRegistry, isToolErrorOutput } from '../tools/registry.js';
|
|
4
|
+
|
|
5
|
+
/** Defaults are safety ceilings, not targets; explicit positive limits override each field. */
|
|
6
|
+
export function resolveSubAgentBudget(budget, persona) {
|
|
7
|
+
return {
|
|
8
|
+
max_tool_calls: persona === 'implementer' ? 128 : 64,
|
|
9
|
+
wall_time_ms: 15 * 60 * 1000,
|
|
10
|
+
...budget,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createExecutionStats() {
|
|
15
|
+
return { toolCalls: 0, completedCalls: 0, failedCalls: 0, repeatedResults: 0, recentCalls: [], warning: null };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fingerprint(value) {
|
|
19
|
+
return createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Structural child-only fence, including aliases and MCP hot registration. The
|
|
24
|
+
* synchronous reservation precedes execute(), so parallel calls cannot overshoot.
|
|
25
|
+
* Parent Active Tool Set checks remain independent and must not be bypassed.
|
|
26
|
+
*/
|
|
27
|
+
export class SubAgentToolRegistry extends ToolRegistry {
|
|
28
|
+
constructor({ allows = () => true, agent = null, stopBudget = null } = {}) {
|
|
29
|
+
super();
|
|
30
|
+
this.allows = allows;
|
|
31
|
+
this.agent = agent;
|
|
32
|
+
this.stopBudget = stopBudget;
|
|
33
|
+
this.recentFingerprints = [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
register(tool) {
|
|
37
|
+
if (this.allows(tool)) super.register(tool);
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async execute(name, input, ctx = {}) {
|
|
42
|
+
const tool = this.get(name);
|
|
43
|
+
if (!tool) throw new Error(`Unknown or disallowed child tool: ${name}`);
|
|
44
|
+
const agent = this.agent;
|
|
45
|
+
if (!agent) return super.execute(name, input, ctx);
|
|
46
|
+
// Serial dispatch can resume after a tool_start yield; never start a write
|
|
47
|
+
// after cancellation, even when the underlying tool ignores AbortSignal.
|
|
48
|
+
const signal = agent.abortController?.signal;
|
|
49
|
+
if (signal?.aborted) throw new Error(String(signal.reason || 'Sub-agent aborted'));
|
|
50
|
+
const stats = agent.execution || (agent.execution = createExecutionStats());
|
|
51
|
+
const limit = agent.budget?.max_tool_calls;
|
|
52
|
+
if (limit !== undefined && stats.toolCalls >= limit) {
|
|
53
|
+
const reason = `max_tool_calls (${limit}) reached; return partial evidence to the parent before extending scope`;
|
|
54
|
+
this.stopBudget?.(reason);
|
|
55
|
+
throw new Error(reason);
|
|
56
|
+
}
|
|
57
|
+
stats.toolCalls += 1;
|
|
58
|
+
agent.usage ||= { tokens: 0, turns: 0, startedAt: Date.now() };
|
|
59
|
+
agent.usage.toolCalls = stats.toolCalls;
|
|
60
|
+
const entry = { name: tool.name, status: 'running' };
|
|
61
|
+
stats.recentCalls.push(entry);
|
|
62
|
+
if (stats.recentCalls.length > 8) stats.recentCalls.shift();
|
|
63
|
+
if (limit && stats.toolCalls >= Math.ceil(limit * 0.75)) {
|
|
64
|
+
stats.warning = 'Tool budget nearly exhausted. Collect the current evidence; do not restart the same investigation.';
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const output = await super.execute(name, input, ctx);
|
|
68
|
+
const failed = tool.errorOutput === 'json-error-envelope' && isToolErrorOutput(output);
|
|
69
|
+
entry.status = failed ? 'error' : 'completed';
|
|
70
|
+
stats.completedCalls += 1;
|
|
71
|
+
if (failed) stats.failedCalls += 1;
|
|
72
|
+
// Advisory only: repeated results are not proof of semantic non-progress.
|
|
73
|
+
// Read ranges, cursors and changed outputs have distinct fingerprints.
|
|
74
|
+
try {
|
|
75
|
+
if (tool.isReadOnly?.(input) === true) {
|
|
76
|
+
const key = fingerprint([tool.name, input, output]);
|
|
77
|
+
if (this.recentFingerprints.includes(key)) stats.repeatedResults += 1;
|
|
78
|
+
this.recentFingerprints.push(key);
|
|
79
|
+
if (this.recentFingerprints.length > 12) this.recentFingerprints.shift();
|
|
80
|
+
}
|
|
81
|
+
} catch { /* Advisory instrumentation must never turn a successful tool into a retry. */ }
|
|
82
|
+
return output;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
entry.status = 'error';
|
|
85
|
+
stats.completedCalls += 1;
|
|
86
|
+
stats.failedCalls += 1;
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -131,6 +131,13 @@ export function diagnoseAgentLiveness(agent, opts = {}) {
|
|
|
131
131
|
&& msSinceActivity >= thresholdMs;
|
|
132
132
|
return {
|
|
133
133
|
...liveness,
|
|
134
|
+
execution: agent?.execution ? {
|
|
135
|
+
...agent.execution,
|
|
136
|
+
recentCalls: agent.execution.recentCalls.map(call => ({ ...call })),
|
|
137
|
+
remainingToolCalls: Math.max(0, (agent.budget?.max_tool_calls || 0) - agent.execution.toolCalls),
|
|
138
|
+
limits: agent.budget,
|
|
139
|
+
progressNote: 'Execution counts and repeated results are diagnostics, not proof of semantic progress or stalling.',
|
|
140
|
+
} : null,
|
|
134
141
|
msSinceLastEvent: liveness.msSinceLastEvent ?? msSinceActivity,
|
|
135
142
|
stale,
|
|
136
143
|
stalled: stale,
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
38
|
import { Engine } from '../engine.js';
|
|
39
|
-
import {
|
|
39
|
+
import { SubAgentToolRegistry, resolveSubAgentBudget, createExecutionStats } from './execution-control.js';
|
|
40
|
+
import { getPersona } from '../personas.js';
|
|
40
41
|
import { buildSpawnedPreamble } from './spawned-prompt.js';
|
|
41
42
|
import { STATUS, isTerminalAgentStatus } from './status.js';
|
|
42
43
|
import { createOutputLog } from './output-log.js';
|
|
@@ -83,8 +84,17 @@ const LAST_RESULT_MAX_CHARS = 8 * 1024;
|
|
|
83
84
|
* @param {ToolRegistry|null} parentRegistry
|
|
84
85
|
* @returns {ToolRegistry}
|
|
85
86
|
*/
|
|
86
|
-
export function buildChildToolRegistry(parentRegistry) {
|
|
87
|
-
const
|
|
87
|
+
export function buildChildToolRegistry(parentRegistry, { agent = null, stopBudget = null } = {}) {
|
|
88
|
+
const preset = agent?.personaData || getPersona(agent?.persona);
|
|
89
|
+
// Implementers retain work tools; read-only roles are a structural allowlist.
|
|
90
|
+
// Resolve legacy template names (Read) to canonical FileRead before filtering.
|
|
91
|
+
const allowed = preset && preset.id !== 'implementer'
|
|
92
|
+
? new Set([...preset.tools.map(name => parentRegistry?.get(name)?.name || (name === 'Read' ? 'FileRead' : name)), 'DiscoverTools'])
|
|
93
|
+
: null;
|
|
94
|
+
const child = new SubAgentToolRegistry({
|
|
95
|
+
agent, stopBudget,
|
|
96
|
+
allows: tool => !RESTRICTED_TOOLS.has(tool.name) && (!allowed || allowed.has(tool.name)),
|
|
97
|
+
});
|
|
88
98
|
if (!parentRegistry || typeof parentRegistry.getAllTools !== 'function') {
|
|
89
99
|
return child;
|
|
90
100
|
}
|
|
@@ -149,7 +159,12 @@ export function startSubAgent(agent, deps = {}) {
|
|
|
149
159
|
// turns must not pollute the user-facing conversation history. The
|
|
150
160
|
// memory stores are shared so memory recall still works for the
|
|
151
161
|
// sub-agent (matches parent VP persona memory).
|
|
152
|
-
|
|
162
|
+
agent.budget = resolveSubAgentBudget(agent.budget, agent.persona);
|
|
163
|
+
agent.execution = agent.execution || createExecutionStats();
|
|
164
|
+
const childRegistry = buildChildToolRegistry(deps.parentToolRegistry, {
|
|
165
|
+
agent,
|
|
166
|
+
stopBudget: reason => stopForBudget(agent, reason),
|
|
167
|
+
});
|
|
153
168
|
subEngine = new Engine({
|
|
154
169
|
adapter: deps.adapter,
|
|
155
170
|
trace: deps.trace,
|
|
@@ -193,6 +208,9 @@ export function startSubAgent(agent, deps = {}) {
|
|
|
193
208
|
parentVpId: deps.parentVpId || null,
|
|
194
209
|
agentName: agent.name,
|
|
195
210
|
mission: agent.mission || agent.task || '',
|
|
211
|
+
expectedOutput: agent.expected_output,
|
|
212
|
+
presetPrompt: (agent.personaData || getPersona(agent.persona))?.systemPrompt,
|
|
213
|
+
budget: agent.budget,
|
|
196
214
|
language: deps.language ?? deps.config?.language ?? 'en',
|
|
197
215
|
});
|
|
198
216
|
|
|
@@ -260,12 +278,21 @@ export function startSubAgent(agent, deps = {}) {
|
|
|
260
278
|
function buildWallTimeBudgetResult(agent, reason) {
|
|
261
279
|
return {
|
|
262
280
|
status: 'budget_exceeded',
|
|
263
|
-
partial_output: agent.partial_output || agent.lastResult
|
|
281
|
+
partial_output: agent.partial_output || agent.lastResult
|
|
282
|
+
|| (typeof agent.result === 'string' ? agent.result : agent.result?.partial_output) || '',
|
|
264
283
|
reason,
|
|
265
284
|
usage: { ...(agent.usage || {}) },
|
|
266
285
|
};
|
|
267
286
|
}
|
|
268
287
|
|
|
288
|
+
function stopForBudget(agent, reason) {
|
|
289
|
+
if (agent.budgetStopReason || isTerminalAgentStatus(agent.status)) return;
|
|
290
|
+
agent.budgetStopReason = reason;
|
|
291
|
+
agent.result = buildWallTimeBudgetResult(agent, reason);
|
|
292
|
+
agent.partial_output = agent.result.partial_output || '';
|
|
293
|
+
agent.abortController?.abort(reason);
|
|
294
|
+
}
|
|
295
|
+
|
|
269
296
|
function armWallTimeWatchdog(agent, deps) {
|
|
270
297
|
const wallTimeMs = agent?.budget?.wall_time_ms;
|
|
271
298
|
if (typeof wallTimeMs !== 'number' || !Number.isFinite(wallTimeMs) || wallTimeMs <= 0) {
|
|
@@ -416,10 +443,15 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
|
|
|
416
443
|
continue;
|
|
417
444
|
}
|
|
418
445
|
|
|
446
|
+
// Partial evidence belongs to this query, never a previous follow-up.
|
|
447
|
+
agent.partial_output = '';
|
|
448
|
+
agent.lastResult = '';
|
|
449
|
+
agent.result = '';
|
|
419
450
|
let assistantText = '';
|
|
420
451
|
let endedNormally = false;
|
|
421
452
|
let streamError = null;
|
|
422
453
|
const turnTokenStart = agent.liveness?.tokenCount || 0;
|
|
454
|
+
const priorUsageTokens = agent.usage?.tokens || 0;
|
|
423
455
|
let turnUsageTokens = 0;
|
|
424
456
|
try {
|
|
425
457
|
const stream = subEngine.query({
|
|
@@ -461,9 +493,16 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
|
|
|
461
493
|
// child is currently saying, not stale text from the prior
|
|
462
494
|
// turn.
|
|
463
495
|
agent.lastResult = capTail(assistantText, LAST_RESULT_MAX_CHARS);
|
|
496
|
+
agent.partial_output = agent.lastResult;
|
|
464
497
|
}
|
|
465
498
|
if (evt && evt.type === 'usage') {
|
|
466
|
-
|
|
499
|
+
const cacheTokens = evt.cacheTokensAreIncludedInInput ? 0
|
|
500
|
+
: (evt.cacheReadTokens || 0) + (evt.cacheWriteTokens || 0);
|
|
501
|
+
turnUsageTokens += (evt.inputTokens || 0) + (evt.outputTokens || 0) + cacheTokens;
|
|
502
|
+
agent.usage.tokens = priorUsageTokens + turnUsageTokens;
|
|
503
|
+
if (agent.budget?.max_tokens && agent.usage.tokens >= agent.budget.max_tokens) {
|
|
504
|
+
stopForBudget(agent, `max_tokens (${agent.budget.max_tokens}) reached`);
|
|
505
|
+
}
|
|
467
506
|
}
|
|
468
507
|
if (evt && evt.type === 'error' && evt.error) {
|
|
469
508
|
streamError = evt.error.message || String(evt.error);
|
|
@@ -475,10 +514,20 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
|
|
|
475
514
|
}
|
|
476
515
|
}
|
|
477
516
|
} catch (err) {
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
517
|
+
if (!agent.budgetStopReason) {
|
|
518
|
+
transitionTerminal(agent, STATUS.FAILED, {
|
|
519
|
+
error: err && err.message ? err.message : String(err),
|
|
520
|
+
diagnostic: 'query_error',
|
|
521
|
+
deps,
|
|
522
|
+
});
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (agent.budgetStopReason) {
|
|
528
|
+
agent.result = buildWallTimeBudgetResult(agent, agent.budgetStopReason);
|
|
529
|
+
transitionTerminal(agent, STATUS.COMPLETED, {
|
|
530
|
+
error: agent.budgetStopReason, diagnostic: 'execution_budget', deps,
|
|
482
531
|
});
|
|
483
532
|
return;
|
|
484
533
|
}
|
|
@@ -527,6 +576,8 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
|
|
|
527
576
|
if (typeof tickAgent === 'function') {
|
|
528
577
|
const textTokenDelta = Math.max(0, (agent.liveness?.tokenCount || 0) - turnTokenStart);
|
|
529
578
|
const tokenDelta = turnUsageTokens > 0 ? turnUsageTokens : textTokenDelta;
|
|
579
|
+
// Usage events are exposed live; tickAgent adds the turn delta once.
|
|
580
|
+
agent.usage.tokens = priorUsageTokens;
|
|
530
581
|
tickResult = tickAgent(agent.id, {
|
|
531
582
|
turns: 1,
|
|
532
583
|
tokens: tokenDelta,
|
|
@@ -612,14 +663,15 @@ function finalizeTerminal(agent, status, { error, deps } = {}) {
|
|
|
612
663
|
};
|
|
613
664
|
try { agent.outputLog?.write(evt); } catch { /* ignore */ }
|
|
614
665
|
if (agent.taskId && deps?.taskManager && agent.parentSessionId) {
|
|
615
|
-
const
|
|
666
|
+
const budgetExceeded = agent.result?.status === 'budget_exceeded';
|
|
667
|
+
const taskStatus = budgetExceeded ? 'failed' : status === STATUS.COMPLETED ? 'succeeded'
|
|
616
668
|
: status === STATUS.CLOSED ? 'cancelled'
|
|
617
669
|
: 'failed';
|
|
618
670
|
try {
|
|
619
671
|
deps.taskManager.completeTask(agent.parentSessionId, agent.taskId, {
|
|
620
672
|
status: taskStatus,
|
|
621
|
-
error: error || agent.error || null,
|
|
622
|
-
summary: status === STATUS.COMPLETED
|
|
673
|
+
error: budgetExceeded ? agent.result.reason : (error || agent.error || null),
|
|
674
|
+
summary: budgetExceeded ? JSON.stringify(agent.result) : status === STATUS.COMPLETED
|
|
623
675
|
? (typeof agent.result === 'string' ? agent.result : (agent.lastResult || null))
|
|
624
676
|
: null,
|
|
625
677
|
});
|
|
@@ -25,8 +25,20 @@
|
|
|
25
25
|
* @param {'en'|'zh'} [args.language='en']
|
|
26
26
|
* @returns {string} preamble block (already ## headed, ready to concat)
|
|
27
27
|
*/
|
|
28
|
-
export function buildSpawnedPreamble({ parentName, parentVpId, agentName, mission, language = 'en' } = {}) {
|
|
29
|
-
|
|
28
|
+
export function buildSpawnedPreamble({ parentName, parentVpId, agentName, mission, expectedOutput, presetPrompt, budget, language = 'en' } = {}) {
|
|
29
|
+
// Resolve template markers before embedding: the outer VP renderer treats
|
|
30
|
+
// markers as sections of the whole soul and would drop the parent + contract.
|
|
31
|
+
const locale = language === 'zh' || language === 'zh-CN' ? 'zh' : 'en';
|
|
32
|
+
const sections = [...(presetPrompt || '').matchAll(/<!-- lang:(\w+) -->([\s\S]*?)(?=<!-- lang:|$)/g)];
|
|
33
|
+
const rolePrompt = sections.length
|
|
34
|
+
? (sections.find(section => section[1] === locale) || sections.find(section => section[1] === 'en'))?.[2]?.trim()
|
|
35
|
+
: presetPrompt;
|
|
36
|
+
const contract = [
|
|
37
|
+
rolePrompt || '',
|
|
38
|
+
expectedOutput ? `## expected_output\nReturn the requested structure; mark unverified facts and blockers honestly.\n${JSON.stringify(expectedOutput)}` : '',
|
|
39
|
+
budget ? `## Execution budget\n${JSON.stringify(budget)}\nLimits are ceilings, not targets. Stop once the mission is answered. Return partial findings before exhausting the budget; do not automatically restart the same work.` : '',
|
|
40
|
+
].filter(Boolean).join('\n\n');
|
|
41
|
+
const m = [(mission || '').trim(), contract].filter(Boolean).join('\n\n');
|
|
30
42
|
if (language === 'zh') {
|
|
31
43
|
const lines = [
|
|
32
44
|
'## 你是 sub-agent',
|
|
@@ -40,7 +52,9 @@ export function buildSpawnedPreamble({ parentName, parentVpId, agentName, missio
|
|
|
40
52
|
'## 行为约束',
|
|
41
53
|
'- 不要再 spawn sub-agent(你已经没有 SpawnAgent / PromptAgent / WaitAgent / CloseAgent 工具)。',
|
|
42
54
|
'- 不要 route_forward 给别的 VP,不要 ask_user。',
|
|
43
|
-
'-
|
|
55
|
+
'- 每次调用必须解决一个仍未解决的问题;先检查最小定向结果,再决定是否扩展。不要重复成功读取的范围、同义搜索或仅为轮询而调用工具。',
|
|
56
|
+
'- 不要把子任务扩展成全项目审计;证据已足够回答时立即收敛。工具活动不等于任务进展。',
|
|
57
|
+
'- 完成时遵守 expected_output;未指定则返回结果、关键证据、实际验证及遗留问题。不要用计划或过程评论冒充最终结果。',
|
|
44
58
|
'- 失败/不可行也要明确说出来,不要假装完成。父 VP 会读你的最终消息。',
|
|
45
59
|
];
|
|
46
60
|
return lines.join('\n');
|
|
@@ -57,7 +71,9 @@ export function buildSpawnedPreamble({ parentName, parentVpId, agentName, missio
|
|
|
57
71
|
'## Constraints',
|
|
58
72
|
'- Do NOT spawn further sub-agents (SpawnAgent / PromptAgent / WaitAgent / CloseAgent are not in your toolset).',
|
|
59
73
|
'- Do NOT use route_forward to other VPs. Do NOT ask_user.',
|
|
60
|
-
'-
|
|
74
|
+
'- Each call must resolve a remaining unknown. Inspect the smallest targeted result before expanding; do not repeat successful read ranges, equivalent searches, or poll tools without a concrete need.',
|
|
75
|
+
'- Do not expand the mission into a whole-project audit. Stop once sufficient evidence answers the mission. Tool activity is not evidence of progress.',
|
|
76
|
+
'- When done, follow expected_output if supplied; otherwise return result, key evidence, actual verification, and open questions. Plans and progress commentary are not final deliverables.',
|
|
61
77
|
'- If the mission is infeasible or you fail, say so plainly. The parent will read your final message.',
|
|
62
78
|
];
|
|
63
79
|
return lines.join('\n');
|
package/yeaft/tools/agent.js
CHANGED
|
@@ -24,6 +24,7 @@ import { defineTool } from './types.js';
|
|
|
24
24
|
import { randomUUID } from 'crypto';
|
|
25
25
|
import { getPersona, listPersonaIds } from '../personas.js';
|
|
26
26
|
import { startSubAgent } from '../sub-agent/runner.js';
|
|
27
|
+
import { resolveSubAgentBudget } from '../sub-agent/execution-control.js';
|
|
27
28
|
import { STATUS, isTerminalAgentStatus } from '../sub-agent/status.js';
|
|
28
29
|
import { diagnoseAgentLiveness, makeLiveness } from '../sub-agent/liveness.js';
|
|
29
30
|
import { TASK_RESULT_DELIVERY } from '../tasks/store.js';
|
|
@@ -89,11 +90,12 @@ export function validateSpec(input) {
|
|
|
89
90
|
return { ok: false, error: 'spec must be an object' };
|
|
90
91
|
}
|
|
91
92
|
const { name, task, mission, expected_output, persona, budget } = input;
|
|
92
|
-
if (!name
|
|
93
|
+
if (!cleanString(name)) {
|
|
93
94
|
return { ok: false, error: 'name is required' };
|
|
94
95
|
}
|
|
95
|
-
if (
|
|
96
|
-
|
|
96
|
+
if ([task, mission].some(value => value !== undefined && !cleanString(value))
|
|
97
|
+
|| (!cleanString(task) && !cleanString(mission))) {
|
|
98
|
+
return { ok: false, error: 'task or mission must be a non-empty string' };
|
|
97
99
|
}
|
|
98
100
|
if (persona && !getPersona(persona)) {
|
|
99
101
|
return {
|
|
@@ -108,9 +110,10 @@ export function validateSpec(input) {
|
|
|
108
110
|
if (typeof budget !== 'object' || budget === null) {
|
|
109
111
|
return { ok: false, error: 'budget must be an object' };
|
|
110
112
|
}
|
|
111
|
-
for (const k of ['max_tokens', 'max_turns', 'wall_time_ms']) {
|
|
112
|
-
if (budget[k] !== undefined && (
|
|
113
|
-
|
|
113
|
+
for (const k of ['max_tokens', 'max_turns', 'wall_time_ms', 'max_tool_calls']) {
|
|
114
|
+
if (budget[k] !== undefined && (!Number.isFinite(budget[k]) || budget[k] <= 0
|
|
115
|
+
|| (['max_turns', 'max_tool_calls'].includes(k) && !Number.isSafeInteger(budget[k])))) {
|
|
116
|
+
return { ok: false, error: `budget.${k} must be finite and positive (counts must be integers)` };
|
|
114
117
|
}
|
|
115
118
|
}
|
|
116
119
|
}
|
|
@@ -122,7 +125,7 @@ export function validateSpec(input) {
|
|
|
122
125
|
task: task || mission,
|
|
123
126
|
expected_output: expected_output || null,
|
|
124
127
|
persona: persona || null,
|
|
125
|
-
budget: budget
|
|
128
|
+
budget: resolveSubAgentBudget(budget, persona),
|
|
126
129
|
},
|
|
127
130
|
};
|
|
128
131
|
}
|
|
@@ -221,8 +224,9 @@ export default defineTool({
|
|
|
221
224
|
en: `Create a sub-agent to work on an independent task in parallel.
|
|
222
225
|
|
|
223
226
|
Sub-agents run in their own context and can be given a concrete mission
|
|
224
|
-
with an optional expected_output schema.
|
|
225
|
-
(
|
|
227
|
+
with an optional expected_output schema. Default safety ceilings: 64 actual tool
|
|
228
|
+
executions (128 for implementer) and 15 minutes; budget overrides each field.
|
|
229
|
+
max_tokens is checked during provider usage; max_turns counts query turns, not tools.
|
|
226
230
|
Pick a preset persona to pre-wire a tool subset and model tier:
|
|
227
231
|
- explorer : fast, read-only scout (Read/Grep/Glob/ListDir)
|
|
228
232
|
- implementer: builder with full work tools (primary model)
|
|
@@ -232,7 +236,8 @@ Pick a preset persona to pre-wire a tool subset and model tier:
|
|
|
232
236
|
Guidelines:
|
|
233
237
|
- Give a clear, focused mission — what "done" looks like
|
|
234
238
|
- Use expected_output when the return shape matters
|
|
235
|
-
-
|
|
239
|
+
- Delegate only a bounded independent result; do simple work directly. Set scope, evidence and stopping conditions in mission.
|
|
240
|
+
- Inspect execution counters and partial evidence before extending budgets; do not respawn the same exhausted mission automatically.
|
|
236
241
|
|
|
237
242
|
Async orchestration:
|
|
238
243
|
1. SpawnAgent — starts the sub-agent as a background task and returns immediately.
|
|
@@ -251,7 +256,8 @@ workflow; use bounded WaitAgent calls and inspect liveness instead of blind loop
|
|
|
251
256
|
zh: `创建一个子 Agent 并行处理独立任务。
|
|
252
257
|
|
|
253
258
|
子 Agent 在独立上下文中运行,可给定具体 mission 和可选的 expected_output schema。
|
|
254
|
-
|
|
259
|
+
默认安全上限:64 次实际工具执行(implementer 为 128 次)、15 分钟;budget 可逐项覆盖。
|
|
260
|
+
max_tokens 在 provider usage 到达时检查;max_turns 是 query turn 数,不是工具调用数。
|
|
255
261
|
选择预设 persona 来预配置工具子集和模型层级:
|
|
256
262
|
- explorer : 快速只读侦察(Read/Grep/Glob/ListDir)
|
|
257
263
|
- implementer: 具备完整工作工具的构建者(主模型)
|
|
@@ -261,7 +267,8 @@ workflow; use bounded WaitAgent calls and inspect liveness instead of blind loop
|
|
|
261
267
|
使用指南:
|
|
262
268
|
- 给出清晰聚焦的 mission——"完成"是什么样子
|
|
263
269
|
- 当返回结构重要时使用 expected_output
|
|
264
|
-
-
|
|
270
|
+
- 只委派有界且独立的结果;简单工作直接做。在 mission 中写清范围、证据和停止条件。
|
|
271
|
+
- 扩大预算前检查实际执行计数和已有证据;不要自动重启同一个耗尽预算的任务。
|
|
265
272
|
|
|
266
273
|
异步编排流程:
|
|
267
274
|
1. SpawnAgent — 启动子 Agent 作为后台任务并立即返回。
|
|
@@ -325,15 +332,19 @@ liveness,不要盲目循环。`
|
|
|
325
332
|
max_turns: { type: 'number', description: {
|
|
326
333
|
en: 'Optional turn ceiling; no default limit is applied',
|
|
327
334
|
zh: '可选 turn 上限;默认不设限制',
|
|
335
|
+
} },
|
|
336
|
+
max_tool_calls: { type: 'integer', minimum: 1, description: {
|
|
337
|
+
en: 'Actual tool execution ceiling; default 64, or 128 for implementer. Includes parallel and discovered tools.',
|
|
338
|
+
zh: '实际工具执行上限;默认 64,implementer 为 128;包括并行及发现的工具。',
|
|
328
339
|
} },
|
|
329
340
|
wall_time_ms: { type: 'number', description: {
|
|
330
|
-
en: '
|
|
331
|
-
zh: '
|
|
341
|
+
en: 'Elapsed-time ceiling in milliseconds; default 900000 (15 minutes)',
|
|
342
|
+
zh: '耗时上限(毫秒);默认 900000(15 分钟)',
|
|
332
343
|
} },
|
|
333
344
|
},
|
|
334
345
|
description: {
|
|
335
|
-
en: '
|
|
336
|
-
zh: '
|
|
346
|
+
en: 'Override default tool/time safety ceilings; token/turn limits are optional. A cutoff returns { status: "budget_exceeded", partial_output, reason }, not successful completion.',
|
|
347
|
+
zh: '覆盖默认工具/时间安全上限;token/turn 限制可选。截止时返回 { status: "budget_exceeded", partial_output, reason },不代表任务成功。',
|
|
337
348
|
},
|
|
338
349
|
},
|
|
339
350
|
cwd: {
|