@yeaft/webchat-agent 1.0.567 → 1.0.569
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/version.json +1 -1
- package/local-runtime/web/app.bundle.js +229 -120
- 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/engine.js +54 -31
- package/yeaft/sub-agent/execution-control.js +11 -0
- package/yeaft/sub-agent/notifications.js +19 -3
- package/yeaft/sub-agent/outcome.js +39 -0
- package/yeaft/sub-agent/runner.js +54 -3
- package/yeaft/sub-agent/status.js +3 -3
- package/yeaft/tasks/manager.js +80 -8
- package/yeaft/tool-folding/index.js +17 -9
- package/yeaft/tool-folding/t1-reflector.js +34 -13
- package/yeaft/tool-folding/t2-reflector.js +34 -13
- package/yeaft/tools/activation.js +2 -0
- package/yeaft/tools/agent.js +4 -1
- package/yeaft/tools/bash.js +30 -5
- package/yeaft/tools/cancel-task.js +4 -1
- package/yeaft/tools/close-agent.js +7 -2
- package/yeaft/tools/enter-worktree.js +6 -2
- package/yeaft/tools/git-read.js +54 -12
- package/yeaft/tools/index.js +2 -0
- package/yeaft/tools/list-agents.js +3 -0
- package/yeaft/tools/list-tasks.js +9 -2
- package/yeaft/tools/read-task-log.js +5 -2
- package/yeaft/tools/registry.js +29 -0
- package/yeaft/tools/update-agent.js +14 -5
- package/yeaft/tools/wait-agent.js +16 -0
- package/yeaft/tools/wait-task.js +73 -0
- package/yeaft/work-center/attachments.js +28 -0
- package/yeaft/work-center/bridge.js +3 -2
- package/yeaft/work-center/controller.js +3 -3
- package/yeaft/work-center/durable-model.js +18 -10
- package/yeaft/work-center/projection.js +27 -3
- package/yeaft/work-center/recurrence.js +103 -0
- package/yeaft/work-center/resource-control.js +2 -4
- package/yeaft/work-center/service.js +30 -3
- package/yeaft/work-center/store.js +137 -24
- package/yeaft/work-center/transaction.js +21 -0
|
Binary file
|
package/package.json
CHANGED
package/yeaft/engine.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* Reference: yeaft-yeaft-implementation-plan.md §3.1, §4 (Phase 2)
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { randomUUID } from 'crypto';
|
|
20
|
+
import { randomUUID, createHash } from 'crypto';
|
|
21
21
|
import { promises as fsp } from 'fs';
|
|
22
22
|
import { join, resolve as resolvePath } from 'path';
|
|
23
23
|
import { buildSystemPrompt, buildWorkerPrompt } from './prompts.js';
|
|
@@ -50,6 +50,13 @@ import { perfNowMs, recordAgentPerfTrace } from './perf-trace.js';
|
|
|
50
50
|
// Default thread marker for legacy / non-group flows. Group VP runtime may
|
|
51
51
|
// pass a real threadId per (sessionId, vpId, threadId) engine instance.
|
|
52
52
|
const MAIN_THREAD_ID = 'main';
|
|
53
|
+
|
|
54
|
+
function foldingMessageKey(message) {
|
|
55
|
+
return message?._persistedMessageId || message?.id || createHash('sha256').update(JSON.stringify([
|
|
56
|
+
message?.role, message?.toolCallId, message?.toolCalls, message?.content,
|
|
57
|
+
])).digest('hex');
|
|
58
|
+
}
|
|
59
|
+
|
|
53
60
|
import { pickEffort, parseEffortPrefix, snapshotEffortDecision } from './effort.js';
|
|
54
61
|
import { bindProviderState } from './llm/provider-state.js';
|
|
55
62
|
import {
|
|
@@ -1666,9 +1673,9 @@ export class Engine {
|
|
|
1666
1673
|
return this.#conversationStore.append(this.#conversationRecord(message, context));
|
|
1667
1674
|
}
|
|
1668
1675
|
|
|
1669
|
-
#persistFoldedRange(
|
|
1676
|
+
#persistFoldedRange(foldedMessages, reflection, context = {}) {
|
|
1670
1677
|
if (!this.#canPersistConversation() || typeof this.#conversationStore?.foldMessages !== 'function') return null;
|
|
1671
|
-
const persistedRows = (
|
|
1678
|
+
const persistedRows = (foldedMessages || [])
|
|
1672
1679
|
.map(message => message?._persistedMessageId || message?.id)
|
|
1673
1680
|
.filter(id => typeof id === 'string' && id)
|
|
1674
1681
|
.map(id => ({ id }));
|
|
@@ -4101,6 +4108,10 @@ export class Engine {
|
|
|
4101
4108
|
assistantText,
|
|
4102
4109
|
language: this.#config.language,
|
|
4103
4110
|
signal,
|
|
4111
|
+
onComplete: diagnostic => this.#trace.log?.('tool_reflection', {
|
|
4112
|
+
sessionId: runtimeSessionId, turnId: queryTurnId,
|
|
4113
|
+
trigger: 't2', model: this.#config.model, ...diagnostic,
|
|
4114
|
+
}),
|
|
4104
4115
|
});
|
|
4105
4116
|
// Detach: never await. The promise outlives this query() and
|
|
4106
4117
|
// the next call will pick it up (or use the fallback stub if
|
|
@@ -4111,6 +4122,12 @@ export class Engine {
|
|
|
4111
4122
|
const info = {
|
|
4112
4123
|
promise,
|
|
4113
4124
|
loopRange: [arcStart, arcEnd],
|
|
4125
|
+
// Absolute indexes do not survive history-window reconstruction.
|
|
4126
|
+
arcKeys: conversationMessages.slice(arcStart, arcEnd + 1).map(foldingMessageKey),
|
|
4127
|
+
arcRows: conversationMessages.slice(arcStart, arcEnd + 1).map(message => ({
|
|
4128
|
+
role: message.role, id: message.id, _persistedMessageId: message._persistedMessageId,
|
|
4129
|
+
toolCallId: message.toolCallId, toolCalls: message.toolCalls?.map(call => ({ id: call.id })),
|
|
4130
|
+
})),
|
|
4114
4131
|
count: pairs.length,
|
|
4115
4132
|
originalUserMsg: prompt,
|
|
4116
4133
|
originatingTurnId: queryTurnId,
|
|
@@ -4904,24 +4921,25 @@ export class Engine {
|
|
|
4904
4921
|
assistantText,
|
|
4905
4922
|
language: this.#config.language,
|
|
4906
4923
|
signal,
|
|
4924
|
+
onComplete: diagnostic => this.#trace.log?.('tool_reflection', {
|
|
4925
|
+
sessionId: runtimeSessionId, turnId: queryTurnId,
|
|
4926
|
+
trigger: 't1', model: this.#config.model, ...diagnostic,
|
|
4927
|
+
}),
|
|
4907
4928
|
});
|
|
4908
|
-
const next = collapseRangeToReflection(
|
|
4929
|
+
const { messages: next, reflection: reflectionMessage, foldedMessages } = collapseRangeToReflection(
|
|
4909
4930
|
conversationMessages, batchStart, batchEnd, content,
|
|
4910
4931
|
);
|
|
4911
|
-
const
|
|
4912
|
-
const durableRowsInRange = conversationMessages
|
|
4913
|
-
.slice(batchStart, batchEnd + 1)
|
|
4932
|
+
const durableRowsInRange = foldedMessages
|
|
4914
4933
|
.some(message => message?._persistedMessageId || message?.id);
|
|
4915
4934
|
const persistedReflection = this.#persistFoldedRange(
|
|
4916
|
-
|
|
4917
|
-
batchStart,
|
|
4918
|
-
batchEnd,
|
|
4935
|
+
foldedMessages,
|
|
4919
4936
|
reflectionMessage,
|
|
4920
4937
|
{ sessionId: runtimeSessionId, model: currentModel, executionOrigin },
|
|
4921
4938
|
);
|
|
4922
4939
|
if (durableRowsInRange && !persistedReflection) {
|
|
4923
4940
|
throw new Error('T1 reflection could not publish its durable range replacement');
|
|
4924
4941
|
}
|
|
4942
|
+
if (persistedReflection?.id) reflectionMessage._persistedMessageId = persistedReflection.id;
|
|
4925
4943
|
// Raw tool rows inside the replacement range are now tombstoned in
|
|
4926
4944
|
// durable history. Late async completions must follow the folded
|
|
4927
4945
|
// continuation path rather than appending to those stale rows.
|
|
@@ -4934,11 +4952,12 @@ export class Engine {
|
|
|
4934
4952
|
}
|
|
4935
4953
|
conversationMessages.length = 0;
|
|
4936
4954
|
for (const m of next) conversationMessages.push(m);
|
|
4937
|
-
//
|
|
4938
|
-
// index `batchStart`. The next tool arc therefore starts
|
|
4955
|
+
// The preserved users precede the reflection. The next arc starts
|
|
4939
4956
|
// immediately after it, i.e. at conversationMessages.length
|
|
4940
4957
|
// (the next assistant message will land here).
|
|
4941
4958
|
arcStartIdx = conversationMessages.length;
|
|
4959
|
+
// Read hints must not imply raw ranges remain in model context.
|
|
4960
|
+
fileReadObservations.clear();
|
|
4942
4961
|
lastT1AtLoopCount = completedToolLoops;
|
|
4943
4962
|
// Bump the success counter — used by the T2 schedule check
|
|
4944
4963
|
// to decide whether T2 still has work to do at end_turn.
|
|
@@ -5180,12 +5199,13 @@ export class Engine {
|
|
|
5180
5199
|
this.#pendingT2.clear();
|
|
5181
5200
|
|
|
5182
5201
|
for (const [turnNumber, info] of drained) {
|
|
5183
|
-
const
|
|
5184
|
-
if (!Array.isArray(
|
|
5185
|
-
const
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5202
|
+
const keys = info.arcKeys;
|
|
5203
|
+
if (!Array.isArray(keys) || keys.length === 0) continue;
|
|
5204
|
+
const currentKeys = conversationMessages.map(foldingMessageKey);
|
|
5205
|
+
const startIdx = currentKeys.findIndex((key, index) => key === keys[0]
|
|
5206
|
+
&& keys.every((expected, offset) => currentKeys[index + offset] === expected));
|
|
5207
|
+
const endIdx = startIdx + keys.length - 1;
|
|
5208
|
+
const rangePresent = startIdx >= 0;
|
|
5189
5209
|
|
|
5190
5210
|
// PR-L follow-up: deterministic readiness check. The info record
|
|
5191
5211
|
// carries `ready / result / error` flags that are flipped from the
|
|
@@ -5214,15 +5234,13 @@ export class Engine {
|
|
|
5214
5234
|
}
|
|
5215
5235
|
|
|
5216
5236
|
// Rewrite history and publish the same logical replacement to disk.
|
|
5217
|
-
const
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5237
|
+
const { messages: next, reflection: reflectionMessage, foldedMessages } = rangePresent
|
|
5238
|
+
? collapseRangeToReflection(conversationMessages, startIdx, endIdx, content)
|
|
5239
|
+
: collapseRangeToReflection(info.arcRows, 0, info.arcRows.length - 1, content);
|
|
5240
|
+
const durableRowsInRange = foldedMessages
|
|
5221
5241
|
.some(message => message?._persistedMessageId || message?.id);
|
|
5222
5242
|
const persistedReflection = this.#persistFoldedRange(
|
|
5223
|
-
|
|
5224
|
-
startIdx,
|
|
5225
|
-
endIdx,
|
|
5243
|
+
foldedMessages,
|
|
5226
5244
|
reflectionMessage,
|
|
5227
5245
|
{
|
|
5228
5246
|
...context,
|
|
@@ -5231,26 +5249,31 @@ export class Engine {
|
|
|
5231
5249
|
},
|
|
5232
5250
|
);
|
|
5233
5251
|
if (durableRowsInRange && !persistedReflection) continue;
|
|
5252
|
+
if (persistedReflection?.id) reflectionMessage._persistedMessageId = persistedReflection.id;
|
|
5234
5253
|
// T2 replaces this arc in durable and in-memory history just like T1.
|
|
5235
5254
|
// Forget its raw result handles before a late async task can append to a
|
|
5236
5255
|
// tombstoned tool row at the next provider boundary.
|
|
5237
|
-
const foldedToolCallIds =
|
|
5238
|
-
.slice(startIdx, endIdx + 1)
|
|
5256
|
+
const foldedToolCallIds = foldedMessages
|
|
5239
5257
|
.filter(message => message?.role === 'tool' && message.toolCallId)
|
|
5240
5258
|
.map(message => message.toolCallId);
|
|
5241
5259
|
for (const toolCallId of foldedToolCallIds) {
|
|
5242
5260
|
this.#persistedToolMessages.delete(toolCallId);
|
|
5243
5261
|
}
|
|
5244
|
-
//
|
|
5245
|
-
|
|
5246
|
-
|
|
5262
|
+
// History may have evicted the old arc. Publish only its original
|
|
5263
|
+
// durable row IDs; never replace unrelated rows at stale indexes.
|
|
5264
|
+
if (rangePresent) {
|
|
5265
|
+
conversationMessages.length = 0;
|
|
5266
|
+
for (const m of next) conversationMessages.push(m);
|
|
5267
|
+
}
|
|
5247
5268
|
|
|
5248
5269
|
yield {
|
|
5249
5270
|
type: 'reflection',
|
|
5250
5271
|
turnId: info.originatingTurnId || null,
|
|
5251
5272
|
trigger,
|
|
5252
5273
|
status: 'ready',
|
|
5253
|
-
|
|
5274
|
+
// Wire identity must match the original pending card, even if the
|
|
5275
|
+
// history window shifted or evicted its internal replacement range.
|
|
5276
|
+
loopRange: info.loopRange,
|
|
5254
5277
|
toolCount: info.count || 0,
|
|
5255
5278
|
content,
|
|
5256
5279
|
durationMs,
|
|
@@ -64,6 +64,14 @@ export class SubAgentToolRegistry extends ToolRegistry {
|
|
|
64
64
|
const limit = agent.budget?.max_tool_calls;
|
|
65
65
|
const llmLimit = agent.budget?.max_llm_calls;
|
|
66
66
|
const llmCalls = agent.usage?.llmCalls || 0;
|
|
67
|
+
if (agent.finalizationRequested) {
|
|
68
|
+
agent.finalizationStarted = true;
|
|
69
|
+
return {
|
|
70
|
+
finalize: true,
|
|
71
|
+
maxOutputTokens: 4096,
|
|
72
|
+
prompt: '[Parent wrap-up control] Stop investigating and do not call tools. Return the final task report now using only evidence already collected. State verification, incomplete work, and blockers honestly; do not claim success merely because the lifecycle is ending.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
67
75
|
const reason = limit && stats.toolCalls >= limit ? `max_tool_calls (${limit}) reached`
|
|
68
76
|
: llmLimit && llmCalls >= llmLimit ? `max_llm_calls (${llmLimit}) reached` : null;
|
|
69
77
|
if (reason) {
|
|
@@ -112,6 +120,9 @@ export class SubAgentToolRegistry extends ToolRegistry {
|
|
|
112
120
|
// after cancellation, even when the underlying tool ignores AbortSignal.
|
|
113
121
|
const signal = agent.abortController?.signal;
|
|
114
122
|
if (signal?.aborted) throw new Error(String(signal.reason || 'Sub-agent aborted'));
|
|
123
|
+
if (agent.finalizationRequested) {
|
|
124
|
+
throw new Error('Parent requested finalization; no further child tools may execute');
|
|
125
|
+
}
|
|
115
126
|
const stats = agent.execution || (agent.execution = createExecutionStats());
|
|
116
127
|
const limit = agent.budget?.max_tool_calls;
|
|
117
128
|
if (limit !== undefined && stats.toolCalls >= limit) {
|
|
@@ -104,6 +104,10 @@ export function enqueueTerminalNotification(input) {
|
|
|
104
104
|
budgetExceeded: Boolean(input.budgetExceeded),
|
|
105
105
|
budgetReason: input.budgetReason || null,
|
|
106
106
|
budgetUsage: input.budgetUsage || null,
|
|
107
|
+
outcome: input.outcome || (input.budgetExceeded ? 'incomplete' : null),
|
|
108
|
+
incomplete: Boolean(input.incomplete || input.budgetExceeded),
|
|
109
|
+
truncated: Boolean(input.truncated),
|
|
110
|
+
finalReport: input.finalReport || null,
|
|
107
111
|
createdAt: Date.now(),
|
|
108
112
|
};
|
|
109
113
|
const key = bucketKey(scope);
|
|
@@ -218,17 +222,29 @@ export function formatNotificationsForPrompt(notifs) {
|
|
|
218
222
|
);
|
|
219
223
|
for (const n of notifs) {
|
|
220
224
|
parts.push('');
|
|
221
|
-
parts.push(`<notification agent="${n.agentName}" id="${n.agentId}" status="${n.status}" turns="${n.turns}">`);
|
|
225
|
+
parts.push(`<notification agent="${n.agentName}" id="${n.agentId}" status="${n.status}" outcome="${n.outcome || 'unknown'}" turns="${n.turns}">`);
|
|
222
226
|
if (n.error) parts.push(` error: ${n.error}`);
|
|
227
|
+
if (n.incomplete) {
|
|
228
|
+
parts.push(' incomplete: true');
|
|
229
|
+
parts.push(' warning: This is partial evidence, not task success; do not treat verdict text such as APPROVE as a completed review.');
|
|
230
|
+
}
|
|
231
|
+
if (n.truncated) parts.push(' truncated: true');
|
|
223
232
|
if (n.budgetExceeded) {
|
|
224
233
|
parts.push(' budgetExceeded: true');
|
|
225
234
|
if (n.budgetReason) parts.push(` budgetReason: ${n.budgetReason}`);
|
|
226
235
|
if (n.budgetUsage) parts.push(` budgetUsage: ${JSON.stringify(n.budgetUsage)}`);
|
|
227
236
|
}
|
|
228
237
|
if (n.outputFile) parts.push(` outputFile: ${n.outputFile}`);
|
|
238
|
+
if (n.finalReport) {
|
|
239
|
+
parts.push(` finalReportReserved: ${Boolean(n.finalReport.reserved)}`);
|
|
240
|
+
parts.push(` finalReportReceived: ${Boolean(n.finalReport.received)}`);
|
|
241
|
+
parts.push(` finalReportTruncated: ${Boolean(n.finalReport.truncated)}`);
|
|
242
|
+
}
|
|
229
243
|
if (n.result) {
|
|
230
|
-
const
|
|
231
|
-
|
|
244
|
+
const clipped = n.result.length > 1500;
|
|
245
|
+
const r = clipped ? n.result.slice(0, 1500) + '…(display truncated)' : n.result;
|
|
246
|
+
if (clipped) parts.push(' displayTruncated: true');
|
|
247
|
+
parts.push(n.incomplete ? ' partialOutput:' : ' result:');
|
|
232
248
|
parts.push(` ${r.split('\n').join('\n ')}`);
|
|
233
249
|
}
|
|
234
250
|
parts.push('</notification>');
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { STATUS, isTerminalAgentStatus } from './status.js';
|
|
2
|
+
|
|
3
|
+
/** Lifecycle state and task outcome are separate contracts. */
|
|
4
|
+
export function describeAgentOutcome(agent) {
|
|
5
|
+
const result = agent?.result;
|
|
6
|
+
const budgetResult = result && typeof result === 'object'
|
|
7
|
+
&& result.status === 'budget_exceeded' ? result : null;
|
|
8
|
+
if (budgetResult) {
|
|
9
|
+
return {
|
|
10
|
+
status: 'incomplete',
|
|
11
|
+
complete: false,
|
|
12
|
+
reason: 'budget_exceeded',
|
|
13
|
+
truncated: Boolean(budgetResult.truncated || budgetResult.final_report?.truncated),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
if (agent?.finalizationRequested) {
|
|
17
|
+
return { status: 'incomplete', complete: false, reason: 'parent_requested_finalization',
|
|
18
|
+
truncated: Boolean(agent.finalReport?.truncated) };
|
|
19
|
+
}
|
|
20
|
+
switch (agent?.status) {
|
|
21
|
+
case STATUS.COMPLETED:
|
|
22
|
+
return { status: 'succeeded', complete: true, reason: null, truncated: false };
|
|
23
|
+
case STATUS.FAILED:
|
|
24
|
+
return { status: 'failed', complete: false, reason: 'execution_failed', truncated: false };
|
|
25
|
+
case STATUS.CLOSED:
|
|
26
|
+
return { status: 'cancelled', complete: false, reason: 'closed', truncated: false };
|
|
27
|
+
case STATUS.ABANDONED:
|
|
28
|
+
return { status: 'incomplete', complete: false, reason: 'abandoned', truncated: false };
|
|
29
|
+
default:
|
|
30
|
+
return { status: 'pending', complete: false, reason: null, truncated: false };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function describeAgentLifecycle(agent) {
|
|
35
|
+
return {
|
|
36
|
+
status: agent?.status || null,
|
|
37
|
+
terminal: isTerminalAgentStatus(agent?.status),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -42,6 +42,7 @@ import { getPersona } from '../personas.js';
|
|
|
42
42
|
import { RESTRICTED_TOOLS, createChildToolPolicy } from './tool-access.js';
|
|
43
43
|
import { buildSpawnedPreamble } from './spawned-prompt.js';
|
|
44
44
|
import { STATUS, isTerminalAgentStatus } from './status.js';
|
|
45
|
+
import { describeAgentOutcome } from './outcome.js';
|
|
45
46
|
import { createOutputLog } from './output-log.js';
|
|
46
47
|
import { makeLiveness, bumpLivenessFromEvent } from './liveness.js';
|
|
47
48
|
import { consumeNotificationForAgent, enqueueTerminalNotification } from './notifications.js';
|
|
@@ -259,6 +260,8 @@ export function startSubAgent(agent, deps = {}) {
|
|
|
259
260
|
function buildWallTimeBudgetResult(agent, reason) {
|
|
260
261
|
return {
|
|
261
262
|
status: 'budget_exceeded',
|
|
263
|
+
outcome: 'incomplete',
|
|
264
|
+
complete: false,
|
|
262
265
|
partial_output: agent.partial_output || agent.lastResult
|
|
263
266
|
|| (typeof agent.result === 'string' ? agent.result : agent.result?.partial_output) || '',
|
|
264
267
|
reason,
|
|
@@ -343,6 +346,19 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
|
|
|
343
346
|
};
|
|
344
347
|
|
|
345
348
|
const dequeueNextUserPrompt = () => {
|
|
349
|
+
if (agent.finalizationRequested && !agent.finalizationStarted) {
|
|
350
|
+
agent.finalizationStarted = true;
|
|
351
|
+
return {
|
|
352
|
+
// The parent's reason is audit evidence, not a child prompt. The
|
|
353
|
+
// registry supplies the authenticated control instruction separately.
|
|
354
|
+
prompt: 'Return the requested evidence-only final report; identify any unchecked scope.',
|
|
355
|
+
finalization: true,
|
|
356
|
+
parentEffortDecision: snapshotEffortDecision(agent.parentEffortDecision),
|
|
357
|
+
projectSessionIds: Array.isArray(deps.projectSessionIds) ? deps.projectSessionIds.slice() : [],
|
|
358
|
+
projectLabel: typeof deps.projectLabel === 'string' ? deps.projectLabel : '',
|
|
359
|
+
projectInstruction: typeof deps.projectInstruction === 'string' ? deps.projectInstruction : '',
|
|
360
|
+
};
|
|
361
|
+
}
|
|
346
362
|
if (!Array.isArray(agent.pendingPrompts)) agent.pendingPrompts = [];
|
|
347
363
|
const entry = agent.pendingPrompts.shift();
|
|
348
364
|
if (!entry) return null;
|
|
@@ -444,7 +460,9 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
|
|
|
444
460
|
agent.result = '';
|
|
445
461
|
let assistantText = '';
|
|
446
462
|
let budgetReportText = '';
|
|
463
|
+
let finalReportText = '';
|
|
447
464
|
let endedNormally = false;
|
|
465
|
+
let outputTruncated = false;
|
|
448
466
|
let streamError = null;
|
|
449
467
|
const priorUsageTokens = agent.usage?.tokens || 0;
|
|
450
468
|
let turnUsageTokens = 0;
|
|
@@ -489,6 +507,7 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
|
|
|
489
507
|
if (evt && evt.type === 'text_delta' && typeof evt.text === 'string') {
|
|
490
508
|
assistantText += evt.text;
|
|
491
509
|
if (agent.budgetReportStarted) budgetReportText += evt.text;
|
|
510
|
+
if (agent.finalizationStarted) finalReportText += evt.text;
|
|
492
511
|
// Mid-stream visibility: keep lastResult fresh so a parent
|
|
493
512
|
// calling WaitAgent during a long generation sees what the
|
|
494
513
|
// child is currently saying, not stale text from the prior
|
|
@@ -509,6 +528,7 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
|
|
|
509
528
|
streamError = evt.error.message || String(evt.error);
|
|
510
529
|
}
|
|
511
530
|
if (evt && evt.type === 'stop') {
|
|
531
|
+
if (evt.stopReason === 'max_tokens') outputTruncated = true;
|
|
512
532
|
if (evt.stopReason === 'end_turn' || evt.stopReason === 'stop_sequence') {
|
|
513
533
|
endedNormally = true;
|
|
514
534
|
}
|
|
@@ -544,6 +564,13 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
|
|
|
544
564
|
const reason = agent.executionBudgetReason || agent.toolBudgetReason;
|
|
545
565
|
agent.result = buildWallTimeBudgetResult(agent, reason);
|
|
546
566
|
agent.result.reporting = { attempted: !!agent.budgetReportStarted, received: !!budgetReportText.trim() };
|
|
567
|
+
agent.result.truncated = outputTruncated;
|
|
568
|
+
agent.result.final_report = {
|
|
569
|
+
reserved: true,
|
|
570
|
+
received: !!budgetReportText.trim(),
|
|
571
|
+
truncated: outputTruncated,
|
|
572
|
+
text: budgetReportText.trim(),
|
|
573
|
+
};
|
|
547
574
|
if (streamError) agent.result.reporting.error = streamError;
|
|
548
575
|
agent.usage.turns += 1;
|
|
549
576
|
agent.result.usage = { ...agent.usage };
|
|
@@ -553,6 +580,18 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
|
|
|
553
580
|
return;
|
|
554
581
|
}
|
|
555
582
|
|
|
583
|
+
if (agent.finalizationRequested) {
|
|
584
|
+
agent.finalReport = { reserved: true, received: !!finalReportText.trim(),
|
|
585
|
+
truncated: outputTruncated, text: finalReportText.trim() };
|
|
586
|
+
agent.result = finalReportText.trim() || assistantText.trim();
|
|
587
|
+
agent.lastResult = capTail(agent.result, LAST_RESULT_MAX_CHARS);
|
|
588
|
+
agent.usage.turns += 1;
|
|
589
|
+
transitionTerminal(agent, STATUS.COMPLETED, {
|
|
590
|
+
error: streamError, diagnostic: 'parent_requested_finalization', deps,
|
|
591
|
+
});
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
556
595
|
if (streamError) {
|
|
557
596
|
transitionTerminal(agent, STATUS.FAILED, {
|
|
558
597
|
error: streamError,
|
|
@@ -673,11 +712,13 @@ function finalizeTerminal(agent, status, { error, deps } = {}) {
|
|
|
673
712
|
if (agent.__terminalNotified) return;
|
|
674
713
|
agent.__terminalNotified = true;
|
|
675
714
|
|
|
715
|
+
const outcome = describeAgentOutcome(agent);
|
|
676
716
|
const evt = {
|
|
677
717
|
type: 'sub_agent_status',
|
|
678
718
|
agentId: agent.id,
|
|
679
719
|
agentName: agent.name,
|
|
680
720
|
status,
|
|
721
|
+
outcome: outcome.status,
|
|
681
722
|
error: error || agent.error || null,
|
|
682
723
|
parentSessionId: agent.parentSessionId || deps?.parentSessionId || null,
|
|
683
724
|
parentVpId: agent.parentVpId || deps?.parentVpId || null,
|
|
@@ -686,14 +727,17 @@ function finalizeTerminal(agent, status, { error, deps } = {}) {
|
|
|
686
727
|
try { agent.outputLog?.write(evt); } catch { /* ignore */ }
|
|
687
728
|
if (agent.taskId && deps?.taskManager && agent.parentSessionId) {
|
|
688
729
|
const budgetExceeded = agent.result?.status === 'budget_exceeded';
|
|
689
|
-
const taskStatus =
|
|
730
|
+
const taskStatus = outcome.status === 'incomplete' ? 'failed' : status === STATUS.COMPLETED ? 'succeeded'
|
|
690
731
|
: status === STATUS.CLOSED ? 'cancelled'
|
|
691
732
|
: 'failed';
|
|
692
733
|
try {
|
|
693
734
|
deps.taskManager.completeTask(agent.parentSessionId, agent.taskId, {
|
|
694
735
|
status: taskStatus,
|
|
695
|
-
error:
|
|
696
|
-
summary: budgetExceeded ? JSON.stringify(agent.result) :
|
|
736
|
+
error: outcome.status === 'incomplete' ? (agent.result?.reason || outcome.reason) : (error || agent.error || null),
|
|
737
|
+
summary: budgetExceeded ? JSON.stringify(agent.result) : agent.finalizationRequested
|
|
738
|
+
? JSON.stringify({ outcome: outcome.status, complete: false, reason: outcome.reason,
|
|
739
|
+
partial_output: agent.result || agent.lastResult || '', final_report: agent.finalReport || null })
|
|
740
|
+
: status === STATUS.COMPLETED
|
|
697
741
|
? (typeof agent.result === 'string' ? agent.result : (agent.lastResult || null))
|
|
698
742
|
: null,
|
|
699
743
|
});
|
|
@@ -731,6 +775,10 @@ function finalizeTerminal(agent, status, { error, deps } = {}) {
|
|
|
731
775
|
budgetExceeded: !!budgetResult,
|
|
732
776
|
budgetReason: budgetResult?.reason || null,
|
|
733
777
|
budgetUsage: budgetResult?.usage || null,
|
|
778
|
+
outcome: outcome.status,
|
|
779
|
+
incomplete: !outcome.complete,
|
|
780
|
+
truncated: outcome.truncated,
|
|
781
|
+
finalReport: budgetResult?.final_report || agent.finalReport || null,
|
|
734
782
|
});
|
|
735
783
|
} catch { /* never let the notification queue throw kill the driver */ }
|
|
736
784
|
}
|
|
@@ -756,6 +804,9 @@ function waitUntilResumed(agent, idleAbandonMs) {
|
|
|
756
804
|
if (Array.isArray(agent.pendingPrompts) && agent.pendingPrompts.length > 0) {
|
|
757
805
|
return resolve('prompt');
|
|
758
806
|
}
|
|
807
|
+
if (agent.finalizationRequested && !agent.finalizationStarted) {
|
|
808
|
+
return resolve('prompt');
|
|
809
|
+
}
|
|
759
810
|
if (idleAbandonMs > 0 && Date.now() - start >= idleAbandonMs) {
|
|
760
811
|
return resolve('abandoned');
|
|
761
812
|
}
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
* - 'running' : the sub-engine is processing a prompt.
|
|
22
22
|
* - 'idle' : the previous turn ended cleanly and the queue is empty.
|
|
23
23
|
* The driver is parked in waitUntilResumed().
|
|
24
|
-
* - 'completed' : terminal
|
|
25
|
-
* budget cutoff
|
|
24
|
+
* - 'completed' : terminal lifecycle state. Inspect the separate outcome;
|
|
25
|
+
* a budget cutoff also ends the lifecycle but is incomplete.
|
|
26
26
|
* - 'failed' : terminal — driver/adapter/stream raised; agent.error set.
|
|
27
27
|
* - 'closed' : terminal — CloseAgent called (or driver finally{} reaped
|
|
28
28
|
* a cleanly-finishing agent).
|
|
@@ -72,7 +72,7 @@ export function describeAgentStatus(status) {
|
|
|
72
72
|
case STATUS.CREATED: return 'just spawned';
|
|
73
73
|
case STATUS.RUNNING: return 'running a turn';
|
|
74
74
|
case STATUS.IDLE: return 'idle (turn ended, queue empty)';
|
|
75
|
-
case STATUS.COMPLETED: return 'completed (terminal)';
|
|
75
|
+
case STATUS.COMPLETED: return 'completed lifecycle (terminal; inspect outcome)';
|
|
76
76
|
case STATUS.FAILED: return 'failed (terminal)';
|
|
77
77
|
case STATUS.CLOSED: return 'closed (terminal)';
|
|
78
78
|
case STATUS.ABANDONED: return 'abandoned by idle watchdog (terminal)';
|
package/yeaft/tasks/manager.js
CHANGED
|
@@ -66,6 +66,7 @@ export class TaskManager {
|
|
|
66
66
|
this.active = new Map();
|
|
67
67
|
this.processes = new Map();
|
|
68
68
|
this.cancelEscalationTimers = new Map();
|
|
69
|
+
this.waiters = new Map();
|
|
69
70
|
this.pendingStartupEvents = [];
|
|
70
71
|
this.#loadPersistedRunningTasks();
|
|
71
72
|
}
|
|
@@ -88,6 +89,20 @@ export class TaskManager {
|
|
|
88
89
|
try { sink?.(event); } catch { /* event sinks must not break tasks */ }
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
#taskForOwner(sessionId, taskId, ownerVpId = null) {
|
|
93
|
+
const task = this.active.get(this.#key(sessionId, taskId)) || this.store.readTask(sessionId, taskId);
|
|
94
|
+
if (!task) return null;
|
|
95
|
+
if (ownerVpId && task.ownerVpId !== ownerVpId) return null;
|
|
96
|
+
return task;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#settleWaiters(key, task) {
|
|
100
|
+
const waiters = this.waiters.get(key);
|
|
101
|
+
if (!waiters) return;
|
|
102
|
+
this.waiters.delete(key);
|
|
103
|
+
for (const settle of [...waiters]) settle(task);
|
|
104
|
+
}
|
|
105
|
+
|
|
91
106
|
#emit(event, task, extra = {}, { deferUntilSink = false } = {}) {
|
|
92
107
|
const payload = { type: 'yeaft_task_event', event, task: publicSnapshot(task), ...extra };
|
|
93
108
|
if (!this.onEvent && deferUntilSink) {
|
|
@@ -246,12 +261,13 @@ export class TaskManager {
|
|
|
246
261
|
this.active.delete(key);
|
|
247
262
|
this.processes.delete(key);
|
|
248
263
|
this.#emit('completed', task);
|
|
264
|
+
this.#settleWaiters(key, task);
|
|
249
265
|
return publicSnapshot(task);
|
|
250
266
|
}
|
|
251
267
|
|
|
252
|
-
cancelTask(sessionId, taskId) {
|
|
268
|
+
cancelTask(sessionId, taskId, ownerVpId = null) {
|
|
253
269
|
const key = this.#key(sessionId, taskId);
|
|
254
|
-
const task = this
|
|
270
|
+
const task = this.#taskForOwner(sessionId, taskId, ownerVpId);
|
|
255
271
|
if (!task) return { ok: false, error: `Unknown task: ${taskId}` };
|
|
256
272
|
if (isTerminalTaskStatus(task.status)) return { ok: true, task: publicSnapshot(task) };
|
|
257
273
|
const runner = this.processes.get(key);
|
|
@@ -312,21 +328,77 @@ export class TaskManager {
|
|
|
312
328
|
return { ok: true, task: publicSnapshot(task), pending: true };
|
|
313
329
|
}
|
|
314
330
|
|
|
315
|
-
listActiveTasks(sessionId = null) {
|
|
316
|
-
const tasks = Array.from(this.active.values()).filter(task =>
|
|
331
|
+
listActiveTasks(sessionId = null, ownerVpId = null) {
|
|
332
|
+
const tasks = Array.from(this.active.values()).filter(task => (
|
|
333
|
+
(!sessionId || task.sessionId === sessionId)
|
|
334
|
+
&& (!ownerVpId || task.ownerVpId === ownerVpId)
|
|
335
|
+
));
|
|
317
336
|
return tasks.map(publicSnapshot);
|
|
318
337
|
}
|
|
319
338
|
|
|
320
|
-
getTask(sessionId, taskId) {
|
|
321
|
-
return publicSnapshot(this
|
|
339
|
+
getTask(sessionId, taskId, ownerVpId = null) {
|
|
340
|
+
return publicSnapshot(this.#taskForOwner(sessionId, taskId, ownerVpId));
|
|
322
341
|
}
|
|
323
342
|
|
|
324
|
-
readTaskLog(sessionId, taskId, opts = {}) {
|
|
325
|
-
const task = this
|
|
343
|
+
readTaskLog(sessionId, taskId, opts = {}, ownerVpId = null) {
|
|
344
|
+
const task = this.#taskForOwner(sessionId, taskId, ownerVpId);
|
|
345
|
+
if (!task) return null;
|
|
326
346
|
if (task?.log?.path) return this.store.readLogFile(task.log.path, opts);
|
|
327
347
|
return this.store.readLog(sessionId, taskId, opts);
|
|
328
348
|
}
|
|
329
349
|
|
|
350
|
+
/** Wait for exactly one task without reading its log into memory. */
|
|
351
|
+
waitForTask(sessionId, taskId, { timeoutMs = 120_000, signal = null, ownerVpId = null } = {}) {
|
|
352
|
+
const key = this.#key(sessionId, taskId);
|
|
353
|
+
const initial = this.#taskForOwner(sessionId, taskId, ownerVpId);
|
|
354
|
+
if (!initial) return Promise.resolve({ ok: false, error: `Unknown task: ${taskId}` });
|
|
355
|
+
if (isTerminalTaskStatus(initial.status)) {
|
|
356
|
+
return Promise.resolve({ ok: true, timedOut: false, task: publicSnapshot(initial) });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return new Promise((resolve, reject) => {
|
|
360
|
+
let timer = null;
|
|
361
|
+
let settled = false;
|
|
362
|
+
const waiters = this.waiters.get(key) || new Set();
|
|
363
|
+
this.waiters.set(key, waiters);
|
|
364
|
+
const cleanup = () => {
|
|
365
|
+
if (timer) clearTimeout(timer);
|
|
366
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
367
|
+
waiters.delete(onComplete);
|
|
368
|
+
if (waiters.size === 0 && this.waiters.get(key) === waiters) this.waiters.delete(key);
|
|
369
|
+
};
|
|
370
|
+
const finish = (value, error = null) => {
|
|
371
|
+
if (settled) return;
|
|
372
|
+
settled = true;
|
|
373
|
+
cleanup();
|
|
374
|
+
if (error) reject(error);
|
|
375
|
+
else resolve(value);
|
|
376
|
+
};
|
|
377
|
+
const onComplete = task => finish({ ok: true, timedOut: false, task: publicSnapshot(task) });
|
|
378
|
+
const onAbort = () => {
|
|
379
|
+
const error = new Error('Task wait aborted');
|
|
380
|
+
error.name = 'AbortError';
|
|
381
|
+
finish(null, error);
|
|
382
|
+
};
|
|
383
|
+
waiters.add(onComplete);
|
|
384
|
+
|
|
385
|
+
// Register before the second read so completion cannot be missed.
|
|
386
|
+
const current = this.#taskForOwner(sessionId, taskId, ownerVpId);
|
|
387
|
+
if (!current) finish({ ok: false, error: `Unknown task: ${taskId}` });
|
|
388
|
+
else if (isTerminalTaskStatus(current.status)) onComplete(current);
|
|
389
|
+
else if (signal?.aborted) onAbort();
|
|
390
|
+
else {
|
|
391
|
+
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
392
|
+
timer = setTimeout(() => {
|
|
393
|
+
const latest = this.#taskForOwner(sessionId, taskId, ownerVpId);
|
|
394
|
+
if (!latest) finish({ ok: false, error: `Unknown task: ${taskId}` });
|
|
395
|
+
else finish({ ok: true, timedOut: !isTerminalTaskStatus(latest.status), task: publicSnapshot(latest) });
|
|
396
|
+
}, Math.max(0, Number.isFinite(timeoutMs) ? Math.floor(timeoutMs) : 120_000));
|
|
397
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
330
402
|
setTaskLogPath(sessionId, taskId, logPath) {
|
|
331
403
|
if (!logPath || typeof logPath !== 'string') return null;
|
|
332
404
|
const key = this.#key(sessionId, taskId);
|