@bubblebrain-ai/bubble 0.0.46 → 0.0.48
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/dist/agent/child-runner.js +11 -0
- package/dist/agent/internal-reminder-sanitizer.d.ts +7 -0
- package/dist/agent/internal-reminder-sanitizer.js +13 -0
- package/dist/agent/subagent/router.d.ts +65 -0
- package/dist/agent/subagent/router.js +198 -0
- package/dist/agent/subagent/runtime.d.ts +203 -0
- package/dist/agent/subagent/runtime.js +789 -0
- package/dist/agent/subagent-control.d.ts +5 -0
- package/dist/agent/subagent-store.d.ts +18 -2
- package/dist/agent/subagent-store.js +32 -1
- package/dist/agent/tool-intent.d.ts +10 -0
- package/dist/agent/tool-intent.js +18 -0
- package/dist/agent/tool-visibility.d.ts +10 -0
- package/dist/agent/tool-visibility.js +6 -0
- package/dist/agent/workflow/runs.d.ts +72 -0
- package/dist/agent/workflow/runs.js +157 -0
- package/dist/agent.d.ts +78 -96
- package/dist/agent.js +266 -1072
- package/dist/context/compact-llm.js +14 -5
- package/dist/context/compact.d.ts +9 -0
- package/dist/context/compact.js +36 -10
- package/dist/context/compaction-files.d.ts +44 -0
- package/dist/context/compaction-files.js +148 -0
- package/dist/context/llm-compactor.js +24 -5
- package/dist/feishu/agent-host/run-driver.js +3 -0
- package/dist/feishu/serve.js +13 -9
- package/dist/goal/tools.js +12 -3
- package/dist/main.js +24 -19
- package/dist/model-catalog.js +36 -2
- package/dist/model-config.d.ts +2 -0
- package/dist/model-data.generated.d.ts +16 -0
- package/dist/model-data.generated.js +275 -0
- package/dist/model-pricing.d.ts +11 -7
- package/dist/model-pricing.js +0 -0
- package/dist/oauth/grok.d.ts +1 -1
- package/dist/oauth/grok.js +5 -2
- package/dist/oauth/storage.d.ts +9 -2
- package/dist/oauth/storage.js +16 -9
- package/dist/orchestrator/default-hooks.d.ts +20 -0
- package/dist/orchestrator/default-hooks.js +47 -295
- package/dist/orchestrator/hooks.d.ts +6 -39
- package/dist/permission/mode.js +1 -1
- package/dist/prompt/reminders.d.ts +9 -35
- package/dist/prompt/reminders.js +30 -136
- package/dist/prompt/task-reminders.d.ts +0 -31
- package/dist/prompt/task-reminders.js +0 -87
- package/dist/provider-ai-sdk.d.ts +2 -0
- package/dist/provider-ai-sdk.js +1 -0
- package/dist/provider-anthropic.d.ts +2 -0
- package/dist/provider-anthropic.js +3 -1
- package/dist/provider-ark-responses.d.ts +2 -0
- package/dist/provider-ark-responses.js +1 -0
- package/dist/provider-grok.d.ts +26 -0
- package/dist/provider-grok.js +39 -0
- package/dist/provider-registry.d.ts +14 -1
- package/dist/provider-registry.js +131 -3
- package/dist/provider-transform.js +37 -1
- package/dist/provider.d.ts +7 -0
- package/dist/provider.js +13 -1
- package/dist/sdk/index.d.ts +152 -0
- package/dist/sdk/index.js +400 -0
- package/dist/session.js +9 -1
- package/dist/slash-commands/commands.js +33 -19
- package/dist/tools/exit-plan-mode.js +8 -0
- package/dist/tui/detect-theme.d.ts +1 -0
- package/dist/tui/detect-theme.js +11 -5
- package/dist/tui-ink/app.d.ts +2 -6
- package/dist/tui-ink/app.js +11 -18
- package/dist/tui-ink/display-history.d.ts +0 -2
- package/dist/tui-ink/display-history.js +0 -52
- package/dist/tui-ink/footer.js +2 -2
- package/dist/tui-ink/input-box.js +18 -6
- package/dist/tui-ink/message-list.js +62 -23
- package/dist/tui-ink/run.js +10 -5
- package/dist/tui-ink/sync-output.d.ts +17 -0
- package/dist/tui-ink/sync-output.js +35 -0
- package/dist/tui-ink/theme.d.ts +0 -1
- package/dist/tui-ink/theme.js +0 -2
- package/dist/tui-ink/trace-groups.js +1 -1
- package/dist/types.d.ts +21 -1
- package/package.json +16 -2
- package/dist/agent/discovery-barrier.d.ts +0 -21
- package/dist/agent/discovery-barrier.js +0 -173
- package/dist/agent/evidence-tracker.d.ts +0 -15
- package/dist/agent/evidence-tracker.js +0 -93
- package/dist/agent/execution-governor.d.ts +0 -36
- package/dist/agent/execution-governor.js +0 -218
- package/dist/agent/task-classifier.d.ts +0 -3
- package/dist/agent/task-classifier.js +0 -114
- package/dist/agent/task-size.d.ts +0 -9
- package/dist/agent/task-size.js +0 -33
- package/dist/agent/tool-arbiter.d.ts +0 -7
- package/dist/agent/tool-arbiter.js +0 -33
- package/dist/orchestrator/workflow.d.ts +0 -10
- package/dist/orchestrator/workflow.js +0 -22
package/dist/agent.js
CHANGED
|
@@ -4,9 +4,6 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { compactCurrentTurnToolGroups, compactMessages, isRealUserMessage } from "./context/compact.js";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
-
import { join } from "node:path";
|
|
9
|
-
import { getBubbleHome } from "./bubble-home.js";
|
|
10
7
|
import { compactMessagesWithLLM } from "./context/compact-llm.js";
|
|
11
8
|
import { estimateContextTokens, getContextBudget } from "./context/budget.js";
|
|
12
9
|
import { buildContextUsageSnapshot } from "./context/usage.js";
|
|
@@ -15,42 +12,24 @@ import { computeRetryDelayMs, isProviderStreamInterruption, MAX_STREAM_INTERRUPT
|
|
|
15
12
|
import { projectMessages } from "./context/projector.js";
|
|
16
13
|
import { aggressivePruneMessages, markStableCurrentToolResultsForCache, markToolResultCacheStable } from "./context/prune.js";
|
|
17
14
|
import { truncateToolOutputForModel } from "./context/tool-output-truncate.js";
|
|
18
|
-
import { buildDeferredToolsReminder, buildToolFreezeReminder, reminderForMode } from "./prompt/reminders.js";
|
|
15
|
+
import { buildDeferredToolsReminder, buildToolFreezeReminder, isPermissionModeReminder, reminderForMode } from "./prompt/reminders.js";
|
|
19
16
|
import { HookBus } from "./orchestrator/hooks.js";
|
|
20
17
|
import { normalizeHookInput, truncateHookText, } from "./hooks/index.js";
|
|
21
18
|
import { createDefaultHooks } from "./orchestrator/default-hooks.js";
|
|
22
19
|
import { buildTaskLifecycleReminder } from "./agent/task-lifecycle-reminder.js";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import { appendOutputSchemaInstructions, buildSchemaCorrectionPrompt, validateStructuredSummary } from "./agent/structured-output.js";
|
|
30
|
-
import { runWorkflow, WorkflowConcurrencyGate } from "./agent/workflow/runtime.js";
|
|
31
|
-
import { buildWorkflowDeliveryNotice, renderWorkflowResultValue } from "./agent/workflow/control.js";
|
|
32
|
-
import { composeAbortSignals } from "./agent/budget-ledger.js";
|
|
33
|
-
import { assignAgentNickname, discoverAgentProfiles, findAgentProfile, validateAgentProfileTools } from "./agent/profiles.js";
|
|
34
|
-
import { snapshotSubagentThread, subagentResultFromThread } from "./agent/subagent-control.js";
|
|
35
|
-
import { SubagentStore } from "./agent/subagent-store.js";
|
|
36
|
-
import { SubagentScheduler } from "./agent/subagent-scheduler.js";
|
|
37
|
-
import { ChildRunner, classifySubagentAbortReason } from "./agent/child-runner.js";
|
|
38
|
-
import { ResultIntegrator } from "./agent/result-integrator.js";
|
|
39
|
-
import { AgentAbortError, EMPTY_ASSISTANT_FALLBACK, SubagentAbortError } from "./agent/abort-errors.js";
|
|
40
|
-
import { createSubagentWorktree, finalizeSubagentWorktree } from "./agent/worktree.js";
|
|
41
|
-
import { createWorktreeChildTools, isolateReadonlyChildFileTools } from "./tools/child-tools.js";
|
|
42
|
-
import { isHiddenToolResult } from "./agent/discovery-barrier.js";
|
|
20
|
+
import { DEFAULT_AGENT_ROUTING, sanitizeAgentRouting } from "./agent/routing-catalog.js";
|
|
21
|
+
import { SubagentRouter } from "./agent/subagent/router.js";
|
|
22
|
+
import { normalizeWaitTimeout, SubagentRuntime } from "./agent/subagent/runtime.js";
|
|
23
|
+
import { WorkflowLedger } from "./agent/workflow/runs.js";
|
|
24
|
+
import { AgentAbortError, EMPTY_ASSISTANT_FALLBACK } from "./agent/abort-errors.js";
|
|
25
|
+
import { isHiddenToolResult } from "./agent/tool-visibility.js";
|
|
43
26
|
import { createStreamingInternalReminderSanitizer, sanitizeAssistantProviderMetadata, sanitizeInternalReasoningText, sanitizeInternalReminderBlocks, } from "./agent/internal-reminder-sanitizer.js";
|
|
44
|
-
import { buildSystemPrompt } from "./system-prompt.js";
|
|
45
27
|
import { debugReasoningStream, summarizeDebugText } from "./reasoning-debug.js";
|
|
46
28
|
import { buildToolPromptOptions } from "./tools/prompt-metadata.js";
|
|
47
29
|
import { stopAutoServersForSession } from "./tools/server-manager.js";
|
|
48
30
|
import { summarizeAgentEventForTrace, summarizeTraceError, summarizeTraceMessage, summarizeTraceToolResult, summarizeTraceValue, traceEvent, } from "./debug-trace.js";
|
|
49
31
|
const MAX_CONSECUTIVE_OVERFLOW_RECOVERIES = 3;
|
|
50
32
|
const RESIDENT_HISTORY_KEEP_RECENT_TURNS = 3;
|
|
51
|
-
const RESIDENT_HISTORY_MESSAGE_LIMIT = 160;
|
|
52
|
-
const RESIDENT_HISTORY_CHAR_SOFT_LIMIT = 256 * 1024;
|
|
53
|
-
const RESIDENT_HISTORY_CHAR_HARD_LIMIT = 512 * 1024;
|
|
54
33
|
const RESIDENT_HISTORY_HEAP_HARD_LIMIT = 768 * 1024 * 1024;
|
|
55
34
|
const MAX_EMPTY_ASSISTANT_RECOVERIES = 1;
|
|
56
35
|
const EMPTY_ASSISTANT_RECOVERY_REMINDER = "The previous model response contained no user-visible assistant content and no tool calls. " +
|
|
@@ -94,10 +73,6 @@ function agentEventFromHookProgress(event) {
|
|
|
94
73
|
error: event.error ?? "Hook failed.",
|
|
95
74
|
};
|
|
96
75
|
}
|
|
97
|
-
/** Detector threshold N (design §6): 1–2 strong-model children are often a
|
|
98
|
-
* deliberate deep side-investigation; at 3+ uniform defaults the odds that
|
|
99
|
-
* all need frontier capability drop sharply. Revisit against live traces. */
|
|
100
|
-
const ROUTING_REMINDER_THRESHOLD = 3;
|
|
101
76
|
export class Agent {
|
|
102
77
|
messages = [];
|
|
103
78
|
provider;
|
|
@@ -116,7 +91,6 @@ export class Agent {
|
|
|
116
91
|
* If the model read the nudge once and chose not to delegate, repeating
|
|
117
92
|
* it is noise.
|
|
118
93
|
*/
|
|
119
|
-
largeTaskNudgeConsumed = false;
|
|
120
94
|
_providerId;
|
|
121
95
|
_model;
|
|
122
96
|
tools = new Map();
|
|
@@ -126,6 +100,7 @@ export class Agent {
|
|
|
126
100
|
_mode;
|
|
127
101
|
_modeVersion = 0;
|
|
128
102
|
onModeUpdate;
|
|
103
|
+
onCompactionApplied;
|
|
129
104
|
_todos;
|
|
130
105
|
_todosVersion = 0;
|
|
131
106
|
onTodosUpdate;
|
|
@@ -144,32 +119,21 @@ export class Agent {
|
|
|
144
119
|
fileStateTracker;
|
|
145
120
|
agentCategories;
|
|
146
121
|
agentRouting;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
/** Detector state (design §6): once per session, absolute defaulted count. */
|
|
150
|
-
routingReminderFired = false;
|
|
151
|
-
defaultedRoutingStreak = 0;
|
|
152
|
-
pendingRoutingReminder;
|
|
122
|
+
/** Owns the routing decision chain, the §6 detector state and the §4 menu. */
|
|
123
|
+
router;
|
|
153
124
|
providerFactory;
|
|
154
|
-
subagentStore;
|
|
155
|
-
subagentScheduler;
|
|
156
|
-
childRunner;
|
|
157
|
-
resultIntegrator = new ResultIntegrator();
|
|
158
|
-
/** Background dynamic-workflow runs (option C Phase 4), keyed by runId. */
|
|
159
|
-
workflowRuns = new Map();
|
|
160
|
-
/** runIds whose completed result should be ingested at the next turn. */
|
|
161
|
-
pendingWorkflowDeliveries = new Set();
|
|
162
|
-
subagentsConfig;
|
|
163
|
-
rateLimitPolicy;
|
|
164
|
-
pendingSubagentUpdates = [];
|
|
165
125
|
/**
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
126
|
+
* Subagent thread lifecycle + workflow script execution: the store,
|
|
127
|
+
* scheduler, ChildRunner and their dispatch/ingestion plumbing.
|
|
128
|
+
*/
|
|
129
|
+
subagents;
|
|
130
|
+
/**
|
|
131
|
+
* Background dynamic-workflow runs (option C Phase 4): run records, waiters,
|
|
132
|
+
* cancellation and the pending-delivery set. Execution stays on Agent.
|
|
171
133
|
*/
|
|
172
|
-
|
|
134
|
+
workflowLedger;
|
|
135
|
+
subagentsConfig;
|
|
136
|
+
rateLimitPolicy;
|
|
173
137
|
lastInputTokens = null;
|
|
174
138
|
lastAnchorMessageCount = null;
|
|
175
139
|
// How often each compaction path actually rewrote history this run.
|
|
@@ -192,6 +156,7 @@ export class Agent {
|
|
|
192
156
|
this.onToolResult = options.onToolResult;
|
|
193
157
|
this.onTodosUpdate = options.onTodosUpdate;
|
|
194
158
|
this.onModeUpdate = options.onModeUpdate;
|
|
159
|
+
this.onCompactionApplied = options.onCompactionApplied;
|
|
195
160
|
this.hookDefinitions = options.hooks ?? [];
|
|
196
161
|
this.externalHooks = options.externalHooks;
|
|
197
162
|
this.agentRole = options.agentRole ?? "parent";
|
|
@@ -207,53 +172,48 @@ export class Agent {
|
|
|
207
172
|
this.agentRouting = options.agentRouting
|
|
208
173
|
? sanitizeAgentRouting(options.agentRouting)
|
|
209
174
|
: { ...DEFAULT_AGENT_ROUTING };
|
|
210
|
-
|
|
211
|
-
|
|
175
|
+
// parentRoute is a thunk, not a snapshot: /model reassigns provider/model/
|
|
176
|
+
// thinking mid-session and children must inherit the CURRENT values.
|
|
177
|
+
this.router = new SubagentRouter({
|
|
178
|
+
parentRoute: () => ({
|
|
179
|
+
providerId: this.providerId,
|
|
180
|
+
model: this.apiModel,
|
|
181
|
+
thinkingLevel: this.thinkingLevel,
|
|
182
|
+
}),
|
|
183
|
+
categories: this.agentCategories,
|
|
184
|
+
routing: this.agentRouting,
|
|
185
|
+
snapshot: options.routingSnapshot,
|
|
186
|
+
routableModels: options.routableModels,
|
|
187
|
+
});
|
|
212
188
|
this.providerFactory = options.providerFactory;
|
|
189
|
+
this.workflowLedger = new WorkflowLedger({
|
|
190
|
+
execute: (cwd, opts) => this.subagents.executeWorkflow(cwd, opts),
|
|
191
|
+
});
|
|
213
192
|
this.subagentsConfig = options.subagents ?? {};
|
|
214
193
|
this.rateLimitPolicy = options.rateLimitPolicy;
|
|
215
|
-
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
});
|
|
234
|
-
this.childRunner = new ChildRunner({
|
|
235
|
-
allTools: () => [...this.tools.values()],
|
|
236
|
-
emit: (record, options, status, event, message) => this.emitSubagentLifecycle(record, options, status, event, message),
|
|
237
|
-
runLifecycleHook: (record, cwd, eventName, status, error, abortSignal) => this.runSubagentLifecycleHookFor(record, cwd, eventName, status, error, abortSignal),
|
|
238
|
-
finalizeBlocked: (record, error, options) => this.finalizeSubagentBlocked(record, error, options),
|
|
239
|
-
createInstance: (record, tools, cwd, forkContext) => this.createSubAgentInstance(record, tools, cwd, forkContext),
|
|
240
|
-
notifyWaiters: (record) => this.subagentStore.notifyWaiters(record),
|
|
241
|
-
onFinal: (record, options) => {
|
|
242
|
-
if (record.worktree) {
|
|
243
|
-
// Inspect and clean up the worktree: unchanged → removed; changed →
|
|
244
|
-
// kept for the parent to review, with a diff stat in the handoff (§8).
|
|
245
|
-
finalizeSubagentWorktree(record.worktree);
|
|
246
|
-
if (record.worktree.changed) {
|
|
247
|
-
record.toolNotes.push(`worktree: changes left in ${record.worktree.path} — review the diff before applying`);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
// Workflow-internal agents are not persisted (they never re-import into
|
|
251
|
-
// the store on restart) and never ingest into parent context (option C).
|
|
252
|
-
if (!record.workflowInternal) {
|
|
253
|
-
this.subagentStore.persist(record);
|
|
254
|
-
this.maybeEnqueueIngestion(record, options);
|
|
255
|
-
}
|
|
194
|
+
const persistDir = this.deriveSubagentPersistDir();
|
|
195
|
+
// `self` so the parent adapter's getters read through to this agent.
|
|
196
|
+
const self = this;
|
|
197
|
+
this.subagents = new SubagentRuntime({
|
|
198
|
+
parent: {
|
|
199
|
+
// Live accessors, never snapshots — /model reassigns these mid-session.
|
|
200
|
+
get provider() { return self.provider; },
|
|
201
|
+
get providerId() { return self.providerId; },
|
|
202
|
+
get apiModel() { return self.apiModel; },
|
|
203
|
+
get thinkingLevel() { return self.thinkingLevel; },
|
|
204
|
+
get memoryPrompt() { return self.memoryPrompt; },
|
|
205
|
+
get providerFactory() { return self.providerFactory; },
|
|
206
|
+
// Unfiltered on purpose: profile admission and child tool selection
|
|
207
|
+
// must see deferred-but-registered tools (an explicit include in a
|
|
208
|
+
// profile is the author pre-unlocking them).
|
|
209
|
+
allTools: () => [...this.tools.values()],
|
|
210
|
+
createChild: (spec) => this.createChildAgent(spec),
|
|
211
|
+
runExternalHook: (input, abortSignal) => this.runExternalHook(input, abortSignal),
|
|
256
212
|
},
|
|
213
|
+
router: this.router,
|
|
214
|
+
categories: this.agentCategories,
|
|
215
|
+
config: this.subagentsConfig,
|
|
216
|
+
persistDir,
|
|
257
217
|
});
|
|
258
218
|
if (options.systemPrompt) {
|
|
259
219
|
this.messages.push({ role: "system", content: options.systemPrompt });
|
|
@@ -292,6 +252,23 @@ export class Agent {
|
|
|
292
252
|
*/
|
|
293
253
|
setSessionID(sessionID) {
|
|
294
254
|
this.sessionID = sessionID;
|
|
255
|
+
// The TUI reuses one Agent across session switches, so the subagent
|
|
256
|
+
// persist directory must follow the session — otherwise children spawned
|
|
257
|
+
// after a switch land in the OLD session's directory and the new
|
|
258
|
+
// session's persisted children never load (known-defects #2).
|
|
259
|
+
this.subagents.repointPersistDir(this.deriveSubagentPersistDir());
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Children persist next to the session file so a later process can resume
|
|
263
|
+
* them via send_input (design §7). An explicit subagents.persistDir config
|
|
264
|
+
* always wins over the sessionID derivation. Child agents themselves
|
|
265
|
+
* (agentRole "subagent") never persist children — no recursion exists.
|
|
266
|
+
*/
|
|
267
|
+
deriveSubagentPersistDir() {
|
|
268
|
+
if (this.agentRole !== "parent")
|
|
269
|
+
return undefined;
|
|
270
|
+
return this.subagentsConfig.persistDir
|
|
271
|
+
?? (this.sessionID?.endsWith(".jsonl") ? this.sessionID.replace(/\.jsonl$/, ".subagents") : undefined);
|
|
295
272
|
}
|
|
296
273
|
getSessionID() {
|
|
297
274
|
return this.sessionID;
|
|
@@ -372,10 +349,7 @@ export class Agent {
|
|
|
372
349
|
}
|
|
373
350
|
/** Current routing menu (design §4); undefined when no accessor is wired. */
|
|
374
351
|
buildModelRoutingPromptSection() {
|
|
375
|
-
|
|
376
|
-
if (!snapshot)
|
|
377
|
-
return undefined;
|
|
378
|
-
return buildModelRoutingPrompt(snapshot, this.agentRouting);
|
|
352
|
+
return this.router.promptSection();
|
|
379
353
|
}
|
|
380
354
|
/**
|
|
381
355
|
* Routing menu rendered for a prospective parent route — used by model-
|
|
@@ -383,14 +357,7 @@ export class Agent {
|
|
|
383
357
|
* (design §1.5).
|
|
384
358
|
*/
|
|
385
359
|
renderModelRoutingPromptFor(parent) {
|
|
386
|
-
|
|
387
|
-
return undefined;
|
|
388
|
-
try {
|
|
389
|
-
return buildModelRoutingPrompt(this.routingSnapshotAccessor(parent), this.agentRouting);
|
|
390
|
-
}
|
|
391
|
-
catch {
|
|
392
|
-
return undefined;
|
|
393
|
-
}
|
|
360
|
+
return this.router.promptSectionFor(parent);
|
|
394
361
|
}
|
|
395
362
|
getContextUsageSnapshot() {
|
|
396
363
|
return buildContextUsageSnapshot({
|
|
@@ -424,11 +391,39 @@ export class Agent {
|
|
|
424
391
|
const last = this.messages.at(-1);
|
|
425
392
|
if (last?.role === "meta"
|
|
426
393
|
&& last.kind === "system-reminder"
|
|
427
|
-
&& last.content === reminder
|
|
394
|
+
&& last.content === reminder
|
|
395
|
+
&& last.includeInLlm !== false) {
|
|
428
396
|
return;
|
|
429
397
|
}
|
|
398
|
+
this.retireStaleModeReminders();
|
|
430
399
|
this.injectSystemReminder(reminder);
|
|
431
400
|
}
|
|
401
|
+
/**
|
|
402
|
+
* Mode reminders are enter-announcements with no cancel semantics, so a stale
|
|
403
|
+
* one keeps describing a mode the session left long ago. Shift+Tab cycles
|
|
404
|
+
* through plan on the way to bypassPermissions, which used to leave a live
|
|
405
|
+
* "Plan mode is now ACTIVE" (plus its "call exit_plan_mode" rule) in history
|
|
406
|
+
* for the rest of the session — the model then planned instead of building,
|
|
407
|
+
* and hunted for a tool that is filtered out outside plan mode.
|
|
408
|
+
*
|
|
409
|
+
* Hidden rather than spliced: keeping the array shape intact leaves message
|
|
410
|
+
* indices (usage anchors, compaction bookkeeping) valid, and the transcript
|
|
411
|
+
* still shows what happened. Cache cost is one invalidated prefix per real
|
|
412
|
+
* mode switch — the no-op path above keeps repeat injections free.
|
|
413
|
+
*/
|
|
414
|
+
retireStaleModeReminders() {
|
|
415
|
+
for (const message of this.messages) {
|
|
416
|
+
if (message.role !== "meta")
|
|
417
|
+
continue;
|
|
418
|
+
if (message.kind !== "system-reminder")
|
|
419
|
+
continue;
|
|
420
|
+
if (message.includeInLlm === false)
|
|
421
|
+
continue;
|
|
422
|
+
if (!isPermissionModeReminder(message.content))
|
|
423
|
+
continue;
|
|
424
|
+
message.includeInLlm = false;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
432
427
|
get role() {
|
|
433
428
|
return this.agentRole;
|
|
434
429
|
}
|
|
@@ -567,19 +562,9 @@ export class Agent {
|
|
|
567
562
|
events.push(...hook.events);
|
|
568
563
|
this.injectHookModelContext(hook.result);
|
|
569
564
|
this.appendMessage({ role: "user", content: input.content });
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
|
|
573
|
-
// the redirected one.
|
|
574
|
-
hookState.exploredFiles?.clear();
|
|
575
|
-
hookState.largeTaskCheckpointDone = false;
|
|
576
|
-
// A steer is a new ask: the completion gate must be able to fire
|
|
577
|
-
// again for the follow-up's closing pass, or appended requirements
|
|
578
|
-
// never get a self-check (codeChanged stays — prior edits are real).
|
|
579
|
-
hookState.completionGateFired = false;
|
|
580
|
-
if (typeof input.content === "string" && input.content.trim()) {
|
|
581
|
-
hookState.taskType = classifyTask(input.content);
|
|
582
|
-
}
|
|
565
|
+
// A steer is a new ask: the tests-touched disclosure must be able to
|
|
566
|
+
// fire again for the follow-up's closing pass.
|
|
567
|
+
hookState.testsTouchedDisclosed = false;
|
|
583
568
|
events.push({
|
|
584
569
|
type: "input_applied",
|
|
585
570
|
id: input.id,
|
|
@@ -622,7 +607,7 @@ export class Agent {
|
|
|
622
607
|
events.push({ type: "input_pending_changed", pending: pendingInputCount() });
|
|
623
608
|
return events;
|
|
624
609
|
};
|
|
625
|
-
const
|
|
610
|
+
const flushQueuedReminders = () => {
|
|
626
611
|
for (const reminder of reminderQueue.splice(0, reminderQueue.length)) {
|
|
627
612
|
this.injectSystemReminder(reminder);
|
|
628
613
|
}
|
|
@@ -660,9 +645,9 @@ export class Agent {
|
|
|
660
645
|
input: userInput,
|
|
661
646
|
state: hookState,
|
|
662
647
|
queueReminder,
|
|
663
|
-
flushReminders:
|
|
648
|
+
flushReminders: flushQueuedReminders,
|
|
664
649
|
});
|
|
665
|
-
|
|
650
|
+
flushQueuedReminders();
|
|
666
651
|
let consecutiveOverflowRecoveries = 0;
|
|
667
652
|
let consecutiveEmptyAssistantRecoveries = 0;
|
|
668
653
|
let consecutiveStreamInterruptionRetries = 0;
|
|
@@ -679,12 +664,14 @@ export class Agent {
|
|
|
679
664
|
try {
|
|
680
665
|
while (true) {
|
|
681
666
|
throwIfAborted(abortSignal);
|
|
682
|
-
|
|
667
|
+
flushQueuedReminders();
|
|
683
668
|
// Background child completions surface before the next inference turn
|
|
684
669
|
// without requiring a wait_agent call (design §5).
|
|
685
|
-
this.
|
|
670
|
+
for (const notice of this.subagents.drainIngestionNotices()) {
|
|
671
|
+
this.injectSystemReminder(notice);
|
|
672
|
+
}
|
|
686
673
|
this.flushWorkflowDeliveries();
|
|
687
|
-
for (const update of this.
|
|
674
|
+
for (const update of this.subagents.drainToolUpdates())
|
|
688
675
|
yield emit(update);
|
|
689
676
|
for (const event of await applyPendingInputs())
|
|
690
677
|
yield emit(event);
|
|
@@ -722,14 +709,15 @@ export class Agent {
|
|
|
722
709
|
currentAssistantMsg = assistantMsg;
|
|
723
710
|
currentAssistantAppended = false;
|
|
724
711
|
let toolEntries = Array.from(this.tools.values())
|
|
725
|
-
.filter((t) => !t.deferred || this.unlockedDeferred.has(t.name))
|
|
712
|
+
.filter((t) => !t.deferred || this.unlockedDeferred.has(t.name))
|
|
713
|
+
.filter((t) => t.enabled?.() !== false);
|
|
726
714
|
const beforeModelCallCtx = {
|
|
727
715
|
agent: this,
|
|
728
716
|
cwd,
|
|
729
717
|
input: userInput,
|
|
730
718
|
state: hookState,
|
|
731
719
|
queueReminder,
|
|
732
|
-
flushReminders:
|
|
720
|
+
flushReminders: flushQueuedReminders,
|
|
733
721
|
toolEntries,
|
|
734
722
|
disableTools: (reason) => {
|
|
735
723
|
hookState.forceTextOnlyReason = reason;
|
|
@@ -754,7 +742,7 @@ export class Agent {
|
|
|
754
742
|
for (const event of preModelHook.events)
|
|
755
743
|
yield emit(event);
|
|
756
744
|
this.injectHookModelContext(preModelHook.result);
|
|
757
|
-
|
|
745
|
+
flushQueuedReminders();
|
|
758
746
|
const textOnly = !!hookState.forceTextOnlyReason;
|
|
759
747
|
const toolDefinitions = toolEntries
|
|
760
748
|
.map((t) => ({
|
|
@@ -769,7 +757,6 @@ export class Agent {
|
|
|
769
757
|
// algorithmic fallback still kicks in.
|
|
770
758
|
await this.maybeCompactWithLLM();
|
|
771
759
|
const bufferedStreamingToolCallIds = new Set();
|
|
772
|
-
const discoveryBarrier = hookState.discoveryBarrier;
|
|
773
760
|
try {
|
|
774
761
|
markStableCurrentToolResultsForCache(this.messages);
|
|
775
762
|
const projectedMessages = projectMessages(this.messages, {
|
|
@@ -848,10 +835,6 @@ export class Agent {
|
|
|
848
835
|
}, traceContext);
|
|
849
836
|
break;
|
|
850
837
|
}
|
|
851
|
-
if (discoveryBarrier?.isEnabled()
|
|
852
|
-
&& (bufferedStreamingToolCallIds.has(chunk.id) || discoveryBarrier.shouldBufferStreamingToolCall(chunk.name))) {
|
|
853
|
-
bufferedStreamingToolCallIds.add(chunk.id);
|
|
854
|
-
}
|
|
855
838
|
if (chunk.isStart) {
|
|
856
839
|
streamingToolCalls.set(chunk.id, { id: chunk.id, name: chunk.name, args: "" });
|
|
857
840
|
if (!bufferedStreamingToolCallIds.has(chunk.id)) {
|
|
@@ -916,7 +899,7 @@ export class Agent {
|
|
|
916
899
|
}
|
|
917
900
|
break;
|
|
918
901
|
}
|
|
919
|
-
for (const update of this.
|
|
902
|
+
for (const update of this.subagents.drainToolUpdates())
|
|
920
903
|
yield emit(update);
|
|
921
904
|
}
|
|
922
905
|
const flushedText = textSanitizer.flush();
|
|
@@ -1032,16 +1015,6 @@ export class Agent {
|
|
|
1032
1015
|
parsedCalls.push({ ...tc, parsedArgs: {}, argsCorrupt: true });
|
|
1033
1016
|
}
|
|
1034
1017
|
}
|
|
1035
|
-
const orderedCalls = hookState.discoveryBarrier?.orderToolCalls(parsedCalls) ?? parsedCalls;
|
|
1036
|
-
if (orderedCalls !== parsedCalls) {
|
|
1037
|
-
parsedCalls.splice(0, parsedCalls.length, ...orderedCalls);
|
|
1038
|
-
assistantMsg.toolCalls = parsedCalls.map((tc) => ({
|
|
1039
|
-
id: tc.id,
|
|
1040
|
-
name: tc.name,
|
|
1041
|
-
arguments: tc.arguments,
|
|
1042
|
-
...(tc.argsCorrupt ? { argsCorrupt: true } : {}),
|
|
1043
|
-
}));
|
|
1044
|
-
}
|
|
1045
1018
|
const executedResults = [];
|
|
1046
1019
|
const appendCancelledToolMessages = (startIndex) => {
|
|
1047
1020
|
for (let pendingIndex = startIndex; pendingIndex < parsedCalls.length; pendingIndex++) {
|
|
@@ -1083,7 +1056,7 @@ export class Agent {
|
|
|
1083
1056
|
input: userInput,
|
|
1084
1057
|
state: hookState,
|
|
1085
1058
|
queueReminder,
|
|
1086
|
-
flushReminders:
|
|
1059
|
+
flushReminders: flushQueuedReminders,
|
|
1087
1060
|
toolCall: tc,
|
|
1088
1061
|
blockedResult,
|
|
1089
1062
|
replaceToolCall: (toolCall) => {
|
|
@@ -1129,7 +1102,7 @@ export class Agent {
|
|
|
1129
1102
|
name: tc.name,
|
|
1130
1103
|
arguments: tc.arguments,
|
|
1131
1104
|
};
|
|
1132
|
-
|
|
1105
|
+
flushQueuedReminders();
|
|
1133
1106
|
if (bufferedStreamingToolCallIds.has(tc.id) && !isHiddenToolResult(blockedResult)) {
|
|
1134
1107
|
yield emit({ type: "tool_call_start", id: tc.id, name: tc.name });
|
|
1135
1108
|
if (tc.arguments) {
|
|
@@ -1151,7 +1124,7 @@ export class Agent {
|
|
|
1151
1124
|
input: userInput,
|
|
1152
1125
|
state: hookState,
|
|
1153
1126
|
queueReminder,
|
|
1154
|
-
flushReminders:
|
|
1127
|
+
flushReminders: flushQueuedReminders,
|
|
1155
1128
|
toolCall: tc,
|
|
1156
1129
|
result,
|
|
1157
1130
|
replaceResult: (next) => {
|
|
@@ -1193,7 +1166,7 @@ export class Agent {
|
|
|
1193
1166
|
isError: result.isError,
|
|
1194
1167
|
});
|
|
1195
1168
|
executedResults.push(result);
|
|
1196
|
-
|
|
1169
|
+
flushQueuedReminders();
|
|
1197
1170
|
continue;
|
|
1198
1171
|
}
|
|
1199
1172
|
const toolStartedAt = Date.now();
|
|
@@ -1228,20 +1201,20 @@ export class Agent {
|
|
|
1228
1201
|
settled = true;
|
|
1229
1202
|
updateQueue.wake();
|
|
1230
1203
|
});
|
|
1231
|
-
this.
|
|
1204
|
+
const unsubscribe = this.subagents.subscribe(updateQueue.wake);
|
|
1232
1205
|
try {
|
|
1233
|
-
while (!settled || updateQueue.hasItems() || this.
|
|
1206
|
+
while (!settled || updateQueue.hasItems() || this.subagents.hasPendingUpdates()) {
|
|
1234
1207
|
for (const update of updateQueue.drain()) {
|
|
1235
1208
|
yield emit({ type: "tool_update", id: tc.id, name: tc.name, update });
|
|
1236
1209
|
}
|
|
1237
|
-
for (const update of this.
|
|
1210
|
+
for (const update of this.subagents.drainToolUpdates())
|
|
1238
1211
|
yield emit(update);
|
|
1239
1212
|
// A wake() that fires while this generator is suspended at a
|
|
1240
|
-
// yield above finds no parked waiter
|
|
1241
|
-
//
|
|
1242
|
-
//
|
|
1243
|
-
//
|
|
1244
|
-
if (!settled && this.
|
|
1213
|
+
// yield above finds no parked waiter. The queue latches such a
|
|
1214
|
+
// wake, and this synchronous re-check covers the same case from
|
|
1215
|
+
// the other side — an update pushed during the yield must not
|
|
1216
|
+
// stall until the next unrelated wake or the tool settles.
|
|
1217
|
+
if (!settled && !this.subagents.hasPendingUpdates()) {
|
|
1245
1218
|
const waitStatus = await updateQueue.wait(abortSignal);
|
|
1246
1219
|
if (waitStatus === "aborted" && !settled) {
|
|
1247
1220
|
cancelledByAbort = true;
|
|
@@ -1251,7 +1224,7 @@ export class Agent {
|
|
|
1251
1224
|
}
|
|
1252
1225
|
}
|
|
1253
1226
|
finally {
|
|
1254
|
-
|
|
1227
|
+
unsubscribe();
|
|
1255
1228
|
}
|
|
1256
1229
|
if (cancelledByAbort) {
|
|
1257
1230
|
result = cancelledToolResult(tc.name);
|
|
@@ -1268,7 +1241,7 @@ export class Agent {
|
|
|
1268
1241
|
input: userInput,
|
|
1269
1242
|
state: hookState,
|
|
1270
1243
|
queueReminder,
|
|
1271
|
-
flushReminders:
|
|
1244
|
+
flushReminders: flushQueuedReminders,
|
|
1272
1245
|
toolCall: tc,
|
|
1273
1246
|
result,
|
|
1274
1247
|
replaceResult: (next) => {
|
|
@@ -1324,11 +1297,11 @@ export class Agent {
|
|
|
1324
1297
|
markToolResultCacheStable(tc.name, toolMessage);
|
|
1325
1298
|
this.appendMessage(toolMessage);
|
|
1326
1299
|
this.compactResidentHistory();
|
|
1327
|
-
|
|
1300
|
+
flushQueuedReminders();
|
|
1328
1301
|
this.onToolResult?.(tc.name, result);
|
|
1329
1302
|
executedResults.push(result);
|
|
1330
1303
|
yield emit({ type: "tool_end", id: tc.id, name: tc.name, result });
|
|
1331
|
-
for (const update of this.
|
|
1304
|
+
for (const update of this.subagents.drainToolUpdates())
|
|
1332
1305
|
yield emit(update);
|
|
1333
1306
|
if (this._todosVersion !== todosVersionBefore) {
|
|
1334
1307
|
yield emit({ type: "todos_updated", todos: this.getTodos() });
|
|
@@ -1347,14 +1320,14 @@ export class Agent {
|
|
|
1347
1320
|
input: userInput,
|
|
1348
1321
|
state: hookState,
|
|
1349
1322
|
queueReminder,
|
|
1350
|
-
flushReminders:
|
|
1323
|
+
flushReminders: flushQueuedReminders,
|
|
1351
1324
|
toolCalls: parsedCalls,
|
|
1352
1325
|
toolResults: executedResults,
|
|
1353
1326
|
requestTextOnlyTurn: (reason) => {
|
|
1354
1327
|
hookState.forceTextOnlyReason = reason;
|
|
1355
1328
|
},
|
|
1356
1329
|
});
|
|
1357
|
-
|
|
1330
|
+
flushQueuedReminders();
|
|
1358
1331
|
yield emit({ type: "turn_end", usage: turnUsage, willContinue: true });
|
|
1359
1332
|
// Auto-continue: if we have tool results, the LLM needs to respond to them.
|
|
1360
1333
|
// Emitting the turn boundary keeps UI renderers aligned with the persisted
|
|
@@ -1368,9 +1341,9 @@ export class Agent {
|
|
|
1368
1341
|
input: userInput,
|
|
1369
1342
|
state: hookState,
|
|
1370
1343
|
queueReminder,
|
|
1371
|
-
flushReminders:
|
|
1344
|
+
flushReminders: flushQueuedReminders,
|
|
1372
1345
|
});
|
|
1373
|
-
|
|
1346
|
+
flushQueuedReminders();
|
|
1374
1347
|
const stopHook = await this.runExternalHook({
|
|
1375
1348
|
eventName: "Stop",
|
|
1376
1349
|
cwd,
|
|
@@ -1396,7 +1369,7 @@ export class Agent {
|
|
|
1396
1369
|
yield emit(event);
|
|
1397
1370
|
break;
|
|
1398
1371
|
}
|
|
1399
|
-
for (const update of this.
|
|
1372
|
+
for (const update of this.subagents.drainToolUpdates())
|
|
1400
1373
|
yield emit(update);
|
|
1401
1374
|
await stopOwnedAutoServers();
|
|
1402
1375
|
yield emit({ type: "agent_end" });
|
|
@@ -1461,6 +1434,7 @@ export class Agent {
|
|
|
1461
1434
|
this.lastInputTokens = null;
|
|
1462
1435
|
this.lastAnchorMessageCount = null;
|
|
1463
1436
|
this.fileStateTracker?.invalidateReadHistory();
|
|
1437
|
+
this.persistCompactionSummary(llmResult.summary);
|
|
1464
1438
|
return before - this.messages.length;
|
|
1465
1439
|
}
|
|
1466
1440
|
// Single-turn capable LLM compactor. compactMessagesWithLLM above no-ops
|
|
@@ -1476,6 +1450,7 @@ export class Agent {
|
|
|
1476
1450
|
this.lastInputTokens = null;
|
|
1477
1451
|
this.lastAnchorMessageCount = null;
|
|
1478
1452
|
this.fileStateTracker?.invalidateReadHistory();
|
|
1453
|
+
this.persistCompactionSummary(singleTurnResult.summary);
|
|
1479
1454
|
return before - this.messages.length;
|
|
1480
1455
|
}
|
|
1481
1456
|
const fallback = compactMessages(this.messages, { keepRecentTurns });
|
|
@@ -1484,6 +1459,7 @@ export class Agent {
|
|
|
1484
1459
|
this.lastInputTokens = null;
|
|
1485
1460
|
this.lastAnchorMessageCount = null;
|
|
1486
1461
|
this.fileStateTracker?.invalidateReadHistory();
|
|
1462
|
+
this.persistCompactionSummary(fallback.summary);
|
|
1487
1463
|
return before - this.messages.length;
|
|
1488
1464
|
}
|
|
1489
1465
|
// Codex-style last-resort: drop the single oldest non-protected message
|
|
@@ -1531,6 +1507,7 @@ export class Agent {
|
|
|
1531
1507
|
this.fileStateTracker?.invalidateReadHistory();
|
|
1532
1508
|
this.compactionStats.llm += 1;
|
|
1533
1509
|
this.compactionStats.fired += 1;
|
|
1510
|
+
this.persistCompactionSummary(result.summary);
|
|
1534
1511
|
traceEvent("compaction_fired", { path: "llm" });
|
|
1535
1512
|
}
|
|
1536
1513
|
// If LLM compaction failed for any reason, leave this.messages alone —
|
|
@@ -1554,7 +1531,16 @@ export class Agent {
|
|
|
1554
1531
|
if (oldMessages.length === 0)
|
|
1555
1532
|
return "";
|
|
1556
1533
|
const { buildCompactionPromptMessages } = await import("./context/compact-llm.js");
|
|
1557
|
-
const
|
|
1534
|
+
const { collectCompactionFileOps, isCompactionSummaryMessage, messageText } = await import("./context/compact.js");
|
|
1535
|
+
const { appendFileBlocks, stripFileBlocks } = await import("./context/compaction-files.js");
|
|
1536
|
+
// File blocks stay out of the model round-trip: stripped from the input
|
|
1537
|
+
// here, merged deterministically, re-appended to the returned summary.
|
|
1538
|
+
const priorSummaries = oldMessages.filter(isCompactionSummaryMessage);
|
|
1539
|
+
const fileOps = collectCompactionFileOps(oldMessages.filter((message) => !isCompactionSummaryMessage(message)), priorSummaries);
|
|
1540
|
+
const promptInput = oldMessages.map((message) => isCompactionSummaryMessage(message) && typeof message.content === "string"
|
|
1541
|
+
? { ...message, content: stripFileBlocks(messageText(message)) }
|
|
1542
|
+
: message);
|
|
1543
|
+
const promptMessages = buildCompactionPromptMessages(promptInput);
|
|
1558
1544
|
const stream = this.provider.streamChat(promptMessages, {
|
|
1559
1545
|
model: this.apiModel,
|
|
1560
1546
|
temperature: 0.2,
|
|
@@ -1571,156 +1557,28 @@ export class Agent {
|
|
|
1571
1557
|
// Strip any internal reminder markup the summarizer may have reproduced from
|
|
1572
1558
|
// the transcript: this summary is both displayed in the compaction card and
|
|
1573
1559
|
// re-injected as a `Previous conversation summary` system message.
|
|
1574
|
-
|
|
1560
|
+
const summary = sanitizeInternalReminderBlocks(full).trim();
|
|
1561
|
+
if (!summary)
|
|
1562
|
+
return "";
|
|
1563
|
+
return appendFileBlocks(summary, fileOps);
|
|
1575
1564
|
}
|
|
1565
|
+
// ---- Subagent lifecycle: delegated to SubagentRuntime. ----
|
|
1566
|
+
// These stay on Agent as one-line forwards so every existing caller
|
|
1567
|
+
// (tools/agent-lifecycle.ts, orchestrator/default-hooks.ts) is unchanged.
|
|
1576
1568
|
async runSubAgent(input, cwd, options) {
|
|
1577
|
-
|
|
1578
|
-
profile: options.profile,
|
|
1579
|
-
task: typeof input === "string" ? input : "(multimodal task)",
|
|
1580
|
-
runId: options.runId,
|
|
1581
|
-
agentId: options.subAgentId,
|
|
1582
|
-
parentToolCallId: options.parentToolCallId,
|
|
1583
|
-
parentToolName: "subagent",
|
|
1584
|
-
nickname: options.nickname,
|
|
1585
|
-
route: options.route ?? this.resolveRouteForSubagent(options.profile, options.category),
|
|
1586
|
-
});
|
|
1587
|
-
this.subagentStore.set(record);
|
|
1588
|
-
const approval = options.approval ?? options.profile.approval;
|
|
1589
|
-
const admissionError = this.admitSubagentProfile(record, approval);
|
|
1590
|
-
if (admissionError) {
|
|
1591
|
-
this.finalizeSubagentBlocked(record, admissionError, { directEmit: options.emitUpdate });
|
|
1592
|
-
return subagentResultFromThread(record);
|
|
1593
|
-
}
|
|
1594
|
-
record.promise = this.dispatchSubagentRun(record, input, cwd, {
|
|
1595
|
-
approval,
|
|
1596
|
-
abortSignal: options.abortSignal,
|
|
1597
|
-
forkContext: options.forkContext,
|
|
1598
|
-
directEmit: options.emitUpdate,
|
|
1599
|
-
});
|
|
1600
|
-
await record.promise;
|
|
1601
|
-
return subagentResultFromThread(record);
|
|
1569
|
+
return this.subagents.runSubAgent(input, cwd, options);
|
|
1602
1570
|
}
|
|
1603
1571
|
async spawnSubAgent(input, cwd, options) {
|
|
1604
|
-
|
|
1605
|
-
?? this.resolveRouteForSubagent(options.profile, options.category, { model: options.model, effort: options.effort });
|
|
1606
|
-
// Early validation (design §7): throws reach the model as a tool error it
|
|
1607
|
-
// can correct this turn, instead of a late provider-factory failure.
|
|
1608
|
-
const routeNote = this.validateRouteForDispatch(route);
|
|
1609
|
-
this.noteRoutingDispatch(route);
|
|
1610
|
-
const record = this.createSubagentThreadRecord({
|
|
1611
|
-
profile: options.profile,
|
|
1612
|
-
task: typeof input === "string" ? input : "(multimodal task)",
|
|
1613
|
-
parentToolCallId: options.parentToolCallId,
|
|
1614
|
-
parentToolName: "spawn_agent",
|
|
1615
|
-
route,
|
|
1616
|
-
});
|
|
1617
|
-
if (routeNote)
|
|
1618
|
-
record.toolNotes.push(routeNote);
|
|
1619
|
-
this.subagentStore.set(record);
|
|
1620
|
-
const approval = options.approval ?? record.profile.approval;
|
|
1621
|
-
// Admission validation runs before queueing (design §4.2): a request that
|
|
1622
|
-
// would block never consumes a queue slot.
|
|
1623
|
-
const admissionError = this.admitSubagentProfile(record, approval);
|
|
1624
|
-
if (admissionError) {
|
|
1625
|
-
this.finalizeSubagentBlocked(record, admissionError, { queueUpdates: true });
|
|
1626
|
-
return this.snapshotSubagent(record);
|
|
1627
|
-
}
|
|
1628
|
-
this.queueSubagentUpdate(record, "queued", undefined, `Queued ${record.nickname} (${record.profile.name})`);
|
|
1629
|
-
record.promise = this.dispatchSubagentRun(record, input, cwd, {
|
|
1630
|
-
approval,
|
|
1631
|
-
abortSignal: options.abortSignal,
|
|
1632
|
-
forkContext: options.forkContext,
|
|
1633
|
-
queueUpdates: true,
|
|
1634
|
-
});
|
|
1635
|
-
void record.promise.finally(() => this.subagentStore.notifyWaiters(record));
|
|
1636
|
-
return this.snapshotSubagent(record);
|
|
1572
|
+
return this.subagents.spawnSubAgent(input, cwd, options);
|
|
1637
1573
|
}
|
|
1638
1574
|
async waitSubAgents(options = {}) {
|
|
1639
|
-
|
|
1640
|
-
if (targets.length === 0)
|
|
1641
|
-
return [];
|
|
1642
|
-
const completed = targets.filter((record) => isFinalSubagentStatus(record.status));
|
|
1643
|
-
if (completed.length > 0) {
|
|
1644
|
-
for (const record of completed)
|
|
1645
|
-
this.subagentStore.markDelivered(record.agentId);
|
|
1646
|
-
return completed.map((record) => this.snapshotSubagent(record));
|
|
1647
|
-
}
|
|
1648
|
-
const timeoutMs = normalizeWaitTimeout(options.timeoutMs);
|
|
1649
|
-
let waiter;
|
|
1650
|
-
await Promise.race([
|
|
1651
|
-
new Promise((resolve) => {
|
|
1652
|
-
waiter = resolve;
|
|
1653
|
-
for (const record of targets) {
|
|
1654
|
-
record.waiters.add(resolve);
|
|
1655
|
-
}
|
|
1656
|
-
}).finally(() => {
|
|
1657
|
-
if (waiter) {
|
|
1658
|
-
for (const record of targets) {
|
|
1659
|
-
record.waiters.delete(waiter);
|
|
1660
|
-
}
|
|
1661
|
-
}
|
|
1662
|
-
}),
|
|
1663
|
-
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
|
1664
|
-
]);
|
|
1665
|
-
const finished = targets.filter((record) => isFinalSubagentStatus(record.status));
|
|
1666
|
-
for (const record of finished)
|
|
1667
|
-
this.subagentStore.markDelivered(record.agentId);
|
|
1668
|
-
return (finished.length > 0 ? finished : targets).map((record) => this.snapshotSubagent(record));
|
|
1575
|
+
return this.subagents.waitSubAgents(options);
|
|
1669
1576
|
}
|
|
1670
1577
|
async sendSubAgentInput(agentId, input, cwd, options = {}) {
|
|
1671
|
-
|
|
1672
|
-
if (!record) {
|
|
1673
|
-
throw new Error(`Unknown subagent: ${agentId}`);
|
|
1674
|
-
}
|
|
1675
|
-
if (record.status === "running" || record.status === "queued") {
|
|
1676
|
-
if (!options.interrupt) {
|
|
1677
|
-
throw new Error(`Subagent ${agentId} is still running. Call wait_agent first or pass interrupt:true.`);
|
|
1678
|
-
}
|
|
1679
|
-
record.abortController.abort(new SubagentAbortError(`Subagent ${agentId} interrupted.`, "interrupt"));
|
|
1680
|
-
await record.promise?.catch(() => undefined);
|
|
1681
|
-
record.abortController = new AbortController();
|
|
1682
|
-
}
|
|
1683
|
-
if (record.status === "closed") {
|
|
1684
|
-
throw new Error(`Subagent ${agentId} is closed.`);
|
|
1685
|
-
}
|
|
1686
|
-
record.parentToolCallId = options.parentToolCallId ?? record.parentToolCallId;
|
|
1687
|
-
record.parentToolName = "send_input";
|
|
1688
|
-
record.task = typeof input === "string" ? input : "(multimodal task)";
|
|
1689
|
-
record.summary = "";
|
|
1690
|
-
record.toolNotes = [];
|
|
1691
|
-
record.usage = undefined;
|
|
1692
|
-
record.error = undefined;
|
|
1693
|
-
record.finalReason = undefined;
|
|
1694
|
-
record.deliveredAt = undefined;
|
|
1695
|
-
record.updatedAt = Date.now();
|
|
1696
|
-
// A send_input restart is a launch like any other: it goes through the
|
|
1697
|
-
// scheduler's dispatch point and is subject to the same admission limits
|
|
1698
|
-
// (design §4.1) — batch-resuming team members cannot bypass concurrency caps.
|
|
1699
|
-
record.promise = this.dispatchSubagentRun(record, input, cwd, {
|
|
1700
|
-
approval: record.profile.approval,
|
|
1701
|
-
abortSignal: options.abortSignal,
|
|
1702
|
-
queueUpdates: true,
|
|
1703
|
-
reuseAgent: true,
|
|
1704
|
-
});
|
|
1705
|
-
void record.promise.finally(() => this.subagentStore.notifyWaiters(record));
|
|
1706
|
-
return this.snapshotSubagent(record);
|
|
1578
|
+
return this.subagents.sendSubAgentInput(agentId, input, cwd, options);
|
|
1707
1579
|
}
|
|
1708
1580
|
async closeSubAgent(agentId) {
|
|
1709
|
-
|
|
1710
|
-
if (!record) {
|
|
1711
|
-
throw new Error(`Unknown subagent: ${agentId}`);
|
|
1712
|
-
}
|
|
1713
|
-
if (!isFinalSubagentStatus(record.status)) {
|
|
1714
|
-
record.abortController.abort(new SubagentAbortError(`Subagent ${agentId} closed.`, "user_close"));
|
|
1715
|
-
await record.promise?.catch(() => undefined);
|
|
1716
|
-
}
|
|
1717
|
-
record.status = "closed";
|
|
1718
|
-
record.finalReason = record.finalReason ?? "cancelled_user";
|
|
1719
|
-
record.updatedAt = Date.now();
|
|
1720
|
-
this.queueSubagentUpdate(record, "cancelled", undefined, `${record.nickname} closed`);
|
|
1721
|
-
this.subagentStore.persist(record);
|
|
1722
|
-
this.subagentStore.notifyWaiters(record);
|
|
1723
|
-
return this.snapshotSubagent(record);
|
|
1581
|
+
return this.subagents.closeSubAgent(agentId);
|
|
1724
1582
|
}
|
|
1725
1583
|
/**
|
|
1726
1584
|
* Live (non-final) children only — the delegation-nudge gate
|
|
@@ -1729,16 +1587,27 @@ export class Agent {
|
|
|
1729
1587
|
* never be used to answer "am I currently delegating?".
|
|
1730
1588
|
*/
|
|
1731
1589
|
activeSubAgentCount() {
|
|
1732
|
-
return this.
|
|
1590
|
+
return this.subagents.activeSubAgentCount();
|
|
1591
|
+
}
|
|
1592
|
+
listSubAgents() {
|
|
1593
|
+
return this.subagents.listSubAgents();
|
|
1594
|
+
}
|
|
1595
|
+
/** Marks a child's full summary as delivered to parent context (design §3.3). */
|
|
1596
|
+
markSubagentDelivered(agentId) {
|
|
1597
|
+
this.subagents.markSubagentDelivered(agentId);
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* The child store, still reachable at its old name. Several tests inspect a
|
|
1601
|
+
* spawned child's record (and its live child Agent) through
|
|
1602
|
+
* `(agent as any).subagentStore`; keeping this forward means the extraction
|
|
1603
|
+
* changed no caller, test or otherwise.
|
|
1604
|
+
*/
|
|
1605
|
+
get subagentStore() {
|
|
1606
|
+
return this.subagents.store;
|
|
1733
1607
|
}
|
|
1734
1608
|
/** run_workflow children are workflowInternal and invisible to listSubAgents. */
|
|
1735
1609
|
hasRunningWorkflow() {
|
|
1736
|
-
return
|
|
1737
|
-
}
|
|
1738
|
-
listSubAgents() {
|
|
1739
|
-
return this.subagentStore.values()
|
|
1740
|
-
.filter((record) => !record.workflowInternal)
|
|
1741
|
-
.map((record) => this.snapshotSubagent(record));
|
|
1610
|
+
return this.workflowLedger.hasRunning();
|
|
1742
1611
|
}
|
|
1743
1612
|
/**
|
|
1744
1613
|
* Dynamic workflow (option C): runs an LLM-authored JS orchestration script in
|
|
@@ -1751,7 +1620,7 @@ export class Agent {
|
|
|
1751
1620
|
* completion and returns the result. Background runs go through startWorkflow.
|
|
1752
1621
|
*/
|
|
1753
1622
|
async runWorkflow(cwd, options) {
|
|
1754
|
-
return this.executeWorkflow(cwd, {
|
|
1623
|
+
return this.subagents.executeWorkflow(cwd, {
|
|
1755
1624
|
script: options.script,
|
|
1756
1625
|
args: options.args,
|
|
1757
1626
|
parentToolCallId: options.parentToolCallId,
|
|
@@ -1767,692 +1636,78 @@ export class Agent {
|
|
|
1767
1636
|
* result is ingested at the next turn. Collect explicitly with waitWorkflow.
|
|
1768
1637
|
*/
|
|
1769
1638
|
startWorkflow(cwd, options) {
|
|
1770
|
-
|
|
1771
|
-
const abortController = new AbortController();
|
|
1772
|
-
const composed = composeAbortSignals([options.abortSignal, abortController.signal]);
|
|
1773
|
-
if (composed) {
|
|
1774
|
-
composed.addEventListener("abort", () => abortController.abort(composed.reason), { once: true });
|
|
1775
|
-
}
|
|
1776
|
-
const record = {
|
|
1777
|
-
runId,
|
|
1778
|
-
title: options.title ?? "workflow",
|
|
1779
|
-
status: "running",
|
|
1780
|
-
agentCount: 0,
|
|
1781
|
-
snapshots: [],
|
|
1782
|
-
logs: [],
|
|
1783
|
-
abortController,
|
|
1784
|
-
waiters: new Set(),
|
|
1785
|
-
createdAt: Date.now(),
|
|
1786
|
-
parentToolCallId: options.parentToolCallId,
|
|
1787
|
-
};
|
|
1788
|
-
this.workflowRuns.set(runId, record);
|
|
1789
|
-
record.promise = this.executeWorkflow(cwd, {
|
|
1790
|
-
script: options.script,
|
|
1791
|
-
args: options.args,
|
|
1792
|
-
parentToolCallId: options.parentToolCallId,
|
|
1793
|
-
abortSignal: abortController.signal,
|
|
1794
|
-
queueUpdates: true,
|
|
1795
|
-
ensureProfileTrusted: options.ensureProfileTrusted,
|
|
1796
|
-
}).then((out) => {
|
|
1797
|
-
record.agentCount = out.agentCount;
|
|
1798
|
-
record.snapshots = out.snapshots;
|
|
1799
|
-
record.logs = out.logs;
|
|
1800
|
-
record.result = out.result;
|
|
1801
|
-
record.status = out.result.ok ? "completed" : (abortController.signal.aborted ? "cancelled" : "failed");
|
|
1802
|
-
if (out.result.ok)
|
|
1803
|
-
record.resultPath = persistWorkflowResult(runId, out.result.value);
|
|
1804
|
-
}, (error) => {
|
|
1805
|
-
record.result = { ok: false, error: error?.message || String(error) };
|
|
1806
|
-
record.status = "failed";
|
|
1807
|
-
}).finally(() => {
|
|
1808
|
-
record.updatedAt = Date.now();
|
|
1809
|
-
this.pendingWorkflowDeliveries.add(runId);
|
|
1810
|
-
for (const waiter of record.waiters)
|
|
1811
|
-
waiter();
|
|
1812
|
-
record.waiters.clear();
|
|
1813
|
-
});
|
|
1814
|
-
return { runId, title: record.title };
|
|
1639
|
+
return this.workflowLedger.start(cwd, options);
|
|
1815
1640
|
}
|
|
1816
1641
|
/** Blocks until a background workflow reaches a final state (or times out). */
|
|
1817
1642
|
async waitWorkflow(runId, timeoutMs) {
|
|
1818
|
-
|
|
1819
|
-
if (!record)
|
|
1820
|
-
return undefined;
|
|
1821
|
-
if (record.status === "running") {
|
|
1822
|
-
const limit = normalizeWaitTimeout(timeoutMs);
|
|
1823
|
-
let waiter;
|
|
1824
|
-
await Promise.race([
|
|
1825
|
-
new Promise((resolve) => { waiter = resolve; record.waiters.add(resolve); }),
|
|
1826
|
-
new Promise((resolve) => setTimeout(resolve, limit)),
|
|
1827
|
-
]).finally(() => { if (waiter)
|
|
1828
|
-
record.waiters.delete(waiter); });
|
|
1829
|
-
}
|
|
1830
|
-
if (record.status !== "running")
|
|
1831
|
-
this.pendingWorkflowDeliveries.delete(runId);
|
|
1832
|
-
return this.snapshotWorkflow(record);
|
|
1643
|
+
return this.workflowLedger.wait(runId, normalizeWaitTimeout(timeoutMs));
|
|
1833
1644
|
}
|
|
1834
1645
|
/** Cancels a running background workflow. */
|
|
1835
1646
|
closeWorkflow(runId) {
|
|
1836
|
-
|
|
1837
|
-
if (!record)
|
|
1838
|
-
return undefined;
|
|
1839
|
-
if (record.status === "running")
|
|
1840
|
-
record.abortController.abort(new Error("workflow cancelled"));
|
|
1841
|
-
return this.snapshotWorkflow(record);
|
|
1647
|
+
return this.workflowLedger.close(runId);
|
|
1842
1648
|
}
|
|
1843
1649
|
listWorkflows() {
|
|
1844
|
-
return
|
|
1845
|
-
}
|
|
1846
|
-
snapshotWorkflow(record) {
|
|
1847
|
-
return {
|
|
1848
|
-
runId: record.runId,
|
|
1849
|
-
title: record.title,
|
|
1850
|
-
status: record.status,
|
|
1851
|
-
agentCount: record.agentCount,
|
|
1852
|
-
result: record.result,
|
|
1853
|
-
resultPath: record.resultPath,
|
|
1854
|
-
logs: record.logs,
|
|
1855
|
-
snapshots: record.snapshots,
|
|
1856
|
-
};
|
|
1650
|
+
return this.workflowLedger.list();
|
|
1857
1651
|
}
|
|
1858
1652
|
/** Injects completed background-workflow results before the next turn (§5 analog). */
|
|
1859
1653
|
flushWorkflowDeliveries() {
|
|
1860
|
-
|
|
1861
|
-
return;
|
|
1862
|
-
for (const runId of [...this.pendingWorkflowDeliveries]) {
|
|
1863
|
-
this.pendingWorkflowDeliveries.delete(runId);
|
|
1864
|
-
const record = this.workflowRuns.get(runId);
|
|
1865
|
-
if (!record || record.status === "running" || record.deliveredAt)
|
|
1866
|
-
continue;
|
|
1867
|
-
record.deliveredAt = Date.now();
|
|
1868
|
-
this.injectSystemReminder(buildWorkflowDeliveryNotice(this.snapshotWorkflow(record)));
|
|
1869
|
-
}
|
|
1870
|
-
}
|
|
1871
|
-
async executeWorkflow(cwd, options) {
|
|
1872
|
-
const profiles = discoverAgentProfiles(cwd, "both").profiles;
|
|
1873
|
-
const runRecords = [];
|
|
1874
|
-
const logs = [];
|
|
1875
|
-
// Per-run isolation (option C review): a concurrency sub-cap below the
|
|
1876
|
-
// global limit so a workflow can't starve interactive subagents.
|
|
1877
|
-
const interactiveReserve = 2;
|
|
1878
|
-
const globalCap = Math.max(1, this.subagentsConfig.maxActiveSubagents ?? 8);
|
|
1879
|
-
const workflowConcurrency = Math.max(1, globalCap - interactiveReserve);
|
|
1880
|
-
const gate = new WorkflowConcurrencyGate(workflowConcurrency);
|
|
1881
|
-
const dispatchAgent = async (spec) => {
|
|
1882
|
-
const baseProfile = findAgentProfile(profiles, spec.opts.agentType ?? "default")
|
|
1883
|
-
?? findAgentProfile(profiles, "default");
|
|
1884
|
-
if (!baseProfile)
|
|
1885
|
-
return { ok: false, error: "no default subagent profile available" };
|
|
1886
|
-
// Workflow agents are readonly-by-default; mode upgrades come only from the
|
|
1887
|
-
// profile, never from the script (security invariant).
|
|
1888
|
-
const unsupported = baseProfile.mode !== "readonly" && baseProfile.mode !== "write_worktree";
|
|
1889
|
-
if (unsupported)
|
|
1890
|
-
return { ok: false, error: `profile "${baseProfile.name}" mode ${baseProfile.mode} not supported` };
|
|
1891
|
-
// Default-no-network: unattended orchestration of net-capable agents is new
|
|
1892
|
-
// authority in aggregate (option C review), so strip web tools unless the
|
|
1893
|
-
// script opts in with agentType pointing at a profile that includes them.
|
|
1894
|
-
const profile = {
|
|
1895
|
-
...baseProfile,
|
|
1896
|
-
tools: { ...baseProfile.tools, exclude: [...(baseProfile.tools.exclude ?? []), "web_fetch", "web_search"] },
|
|
1897
|
-
};
|
|
1898
|
-
let route;
|
|
1899
|
-
let routeNote;
|
|
1900
|
-
try {
|
|
1901
|
-
route = this.resolveRouteForSubagent(profile, spec.opts.category, {
|
|
1902
|
-
model: spec.opts.model,
|
|
1903
|
-
effort: parseThinkingLevel(spec.opts.effort),
|
|
1904
|
-
});
|
|
1905
|
-
// Dispatch-time validation + defaulted-fan-out accounting (§6–7):
|
|
1906
|
-
// resolved routes, never script source text.
|
|
1907
|
-
routeNote = this.validateRouteForDispatch(route);
|
|
1908
|
-
this.noteRoutingDispatch(route);
|
|
1909
|
-
}
|
|
1910
|
-
catch (error) {
|
|
1911
|
-
return { ok: false, error: error?.message || String(error) };
|
|
1912
|
-
}
|
|
1913
|
-
const baseTask = spec.opts.schema !== undefined
|
|
1914
|
-
? appendOutputSchemaInstructions(spec.prompt, spec.opts.schema)
|
|
1915
|
-
: spec.prompt;
|
|
1916
|
-
const record = this.createSubagentThreadRecord({
|
|
1917
|
-
profile,
|
|
1918
|
-
task: baseTask,
|
|
1919
|
-
parentToolCallId: options.parentToolCallId,
|
|
1920
|
-
parentToolName: "run_workflow",
|
|
1921
|
-
route,
|
|
1922
|
-
workflowInternal: true,
|
|
1923
|
-
});
|
|
1924
|
-
record.expectsStructuredOutput = spec.opts.schema !== undefined;
|
|
1925
|
-
if (routeNote)
|
|
1926
|
-
record.toolNotes.push(routeNote);
|
|
1927
|
-
const memberLabel = typeof spec.opts.label === "string" ? spec.opts.label.trim().slice(0, 40) : "";
|
|
1928
|
-
if (memberLabel)
|
|
1929
|
-
record.nickname = memberLabel;
|
|
1930
|
-
runRecords.push(record);
|
|
1931
|
-
this.subagentStore.set(record);
|
|
1932
|
-
// Project-local profiles pass the same first-use trust gate as
|
|
1933
|
-
// spawn_agent: a .bubble/agents profile must never gain a side door
|
|
1934
|
-
// into execution just because a script named it (Codex review on #58).
|
|
1935
|
-
// Checked AFTER the record exists so a rejected member still shows up
|
|
1936
|
-
// in the run's counts/snapshots as blocked instead of vanishing.
|
|
1937
|
-
if (options.ensureProfileTrusted) {
|
|
1938
|
-
const blocked = await options.ensureProfileTrusted(baseProfile);
|
|
1939
|
-
if (blocked) {
|
|
1940
|
-
const message = typeof blocked.content === "string" ? blocked.content : `profile "${baseProfile.name}" requires user approval`;
|
|
1941
|
-
this.finalizeSubagentBlocked(record, message, { directEmit: options.directEmit, queueUpdates: options.queueUpdates });
|
|
1942
|
-
return { ok: false, error: message };
|
|
1943
|
-
}
|
|
1944
|
-
}
|
|
1945
|
-
const admissionError = this.admitSubagentProfile(record, profile.approval);
|
|
1946
|
-
if (admissionError) {
|
|
1947
|
-
this.finalizeSubagentBlocked(record, admissionError, { directEmit: options.directEmit, queueUpdates: options.queueUpdates });
|
|
1948
|
-
return { ok: false, error: admissionError };
|
|
1949
|
-
}
|
|
1950
|
-
// Leaf-only concurrency permit (option C review M5): held ONLY around this
|
|
1951
|
-
// agent's dispatch, never across parallel/pipeline composition.
|
|
1952
|
-
await gate.acquire();
|
|
1953
|
-
try {
|
|
1954
|
-
record.promise = this.dispatchSubagentRun(record, baseTask, cwd, {
|
|
1955
|
-
approval: profile.approval,
|
|
1956
|
-
abortSignal: options.abortSignal,
|
|
1957
|
-
directEmit: options.directEmit,
|
|
1958
|
-
queueUpdates: options.queueUpdates,
|
|
1959
|
-
});
|
|
1960
|
-
await record.promise;
|
|
1961
|
-
}
|
|
1962
|
-
finally {
|
|
1963
|
-
gate.release();
|
|
1964
|
-
}
|
|
1965
|
-
this.subagentStore.markDelivered(record.agentId);
|
|
1966
|
-
if (record.status !== "completed") {
|
|
1967
|
-
return { ok: false, error: record.error || `agent ${record.nickname} ended: ${record.finalReason ?? record.status}` };
|
|
1968
|
-
}
|
|
1969
|
-
if (spec.opts.schema === undefined) {
|
|
1970
|
-
return { ok: true, value: record.summary };
|
|
1971
|
-
}
|
|
1972
|
-
// Structured output: validate, one corrective retry, then fall back to raw.
|
|
1973
|
-
// The schema: log lines are the telemetry that decides whether the
|
|
1974
|
-
// deferred terminate-style structured-output tool ever gets reopened.
|
|
1975
|
-
let validated = validateStructuredSummary(record.summary, spec.opts.schema);
|
|
1976
|
-
if (!validated.ok) {
|
|
1977
|
-
logs.push(`schema: ${record.nickname} first output failed validation; sending one corrective retry`);
|
|
1978
|
-
try {
|
|
1979
|
-
await this.sendSubAgentInput(record.agentId, buildSchemaCorrectionPrompt(spec.opts.schema, record.summary), cwd, { abortSignal: options.abortSignal });
|
|
1980
|
-
await record.promise?.catch(() => undefined);
|
|
1981
|
-
validated = validateStructuredSummary(record.summary, spec.opts.schema);
|
|
1982
|
-
}
|
|
1983
|
-
catch {
|
|
1984
|
-
// resume failed; fall through to raw summary
|
|
1985
|
-
}
|
|
1986
|
-
logs.push(validated.ok
|
|
1987
|
-
? `schema: ${record.nickname} corrective retry produced valid output`
|
|
1988
|
-
: `schema: ${record.nickname} corrective retry still invalid; returning raw summary`);
|
|
1989
|
-
}
|
|
1990
|
-
return { ok: true, value: validated.ok ? validated.value : record.summary };
|
|
1991
|
-
};
|
|
1992
|
-
const result = await runWorkflow({
|
|
1993
|
-
script: options.script,
|
|
1994
|
-
args: options.args,
|
|
1995
|
-
dispatchAgent,
|
|
1996
|
-
onLog: (message) => logs.push(message),
|
|
1997
|
-
onPhase: (title) => logs.push(`— phase: ${title} —`),
|
|
1998
|
-
budget: {
|
|
1999
|
-
// The ledger is pure accounting (no pool limit); scripts see an
|
|
2000
|
-
// unlimited budget unless a future host contract reintroduces one.
|
|
2001
|
-
total: null,
|
|
2002
|
-
spent: () => runRecords.reduce((sum, r) => sum + (r.usage ? r.usage.promptTokens + r.usage.completionTokens : 0), 0),
|
|
2003
|
-
remaining: () => Number.POSITIVE_INFINITY,
|
|
2004
|
-
},
|
|
2005
|
-
signal: options.abortSignal,
|
|
2006
|
-
});
|
|
2007
|
-
return {
|
|
2008
|
-
result,
|
|
2009
|
-
agentCount: runRecords.length,
|
|
2010
|
-
logs,
|
|
2011
|
-
snapshots: runRecords.map((record) => this.snapshotSubagent(record)),
|
|
2012
|
-
};
|
|
2013
|
-
}
|
|
2014
|
-
/** Marks a child's full summary as delivered to parent context (design §3.3). */
|
|
2015
|
-
markSubagentDelivered(agentId) {
|
|
2016
|
-
this.subagentStore.markDelivered(agentId);
|
|
2017
|
-
}
|
|
2018
|
-
snapshotSubagent(record) {
|
|
2019
|
-
const snapshot = snapshotSubagentThread(record);
|
|
2020
|
-
if (record.status === "queued") {
|
|
2021
|
-
const queuePosition = this.subagentScheduler.queuePosition(record.agentId);
|
|
2022
|
-
if (queuePosition !== undefined)
|
|
2023
|
-
return { ...snapshot, queuePosition };
|
|
2024
|
-
}
|
|
2025
|
-
return snapshot;
|
|
2026
|
-
}
|
|
2027
|
-
/** Returns the blocking diagnostic message when the profile cannot run, else undefined. */
|
|
2028
|
-
admitSubagentProfile(record, approval) {
|
|
2029
|
-
const diagnostics = validateAgentProfileTools([...this.tools.values()], record.profile, approval);
|
|
2030
|
-
const blocking = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
2031
|
-
if (blocking.length === 0)
|
|
2032
|
-
return undefined;
|
|
2033
|
-
return blocking.map((diagnostic) => diagnostic.message).join("\n");
|
|
2034
|
-
}
|
|
2035
|
-
/**
|
|
2036
|
-
* Background children (queueUpdates) get their results ingested before the
|
|
2037
|
-
* parent's next inference turn (design §5); foreground children (team,
|
|
2038
|
-
* legacy task) deliver through their tool result instead.
|
|
2039
|
-
*/
|
|
2040
|
-
maybeEnqueueIngestion(record, options) {
|
|
2041
|
-
if (options.queueUpdates) {
|
|
2042
|
-
this.resultIntegrator.enqueue(record.agentId);
|
|
2043
|
-
}
|
|
2044
|
-
}
|
|
2045
|
-
flushSubagentIngestions() {
|
|
2046
|
-
if (!this.resultIntegrator.hasPending())
|
|
2047
|
-
return;
|
|
2048
|
-
for (const notice of this.resultIntegrator.drainNotices(this.subagentStore)) {
|
|
1654
|
+
for (const notice of this.workflowLedger.drainDeliveryNotices()) {
|
|
2049
1655
|
this.injectSystemReminder(notice);
|
|
2050
1656
|
}
|
|
2051
1657
|
}
|
|
2052
|
-
finalizeSubagentBlocked(record, error, emitOptions) {
|
|
2053
|
-
record.status = "blocked";
|
|
2054
|
-
record.finalReason = "blocked";
|
|
2055
|
-
record.error = error;
|
|
2056
|
-
record.updatedAt = Date.now();
|
|
2057
|
-
this.emitSubagentLifecycle(record, emitOptions, "blocked", undefined, error);
|
|
2058
|
-
this.subagentStore.persist(record);
|
|
2059
|
-
this.subagentStore.notifyWaiters(record);
|
|
2060
|
-
}
|
|
2061
|
-
dispatchSubagentRun(record, input, cwd, options) {
|
|
2062
|
-
record.status = "queued";
|
|
2063
|
-
record.updatedAt = Date.now();
|
|
2064
|
-
const queueSignal = composeAbortSignals([options.abortSignal, record.abortController.signal]);
|
|
2065
|
-
return this.subagentScheduler.dispatch({
|
|
2066
|
-
agentId: record.agentId,
|
|
2067
|
-
category: record.category,
|
|
2068
|
-
signal: queueSignal,
|
|
2069
|
-
run: (ctx) => this.runSubagentThread(record, input, cwd, { ...options, attempt: ctx.attempt }),
|
|
2070
|
-
onCancelledWhileQueued: (reason) => {
|
|
2071
|
-
record.status = "cancelled";
|
|
2072
|
-
record.finalReason = classifySubagentAbortReason(reason, options.abortSignal);
|
|
2073
|
-
record.error = reason instanceof Error ? reason.message : reason ? String(reason) : "Cancelled while queued.";
|
|
2074
|
-
record.updatedAt = Date.now();
|
|
2075
|
-
// The run never started, so no SubagentStart fired and no SubagentStop follows.
|
|
2076
|
-
this.emitSubagentLifecycle(record, options, "cancelled", undefined, record.error);
|
|
2077
|
-
this.subagentStore.persist(record);
|
|
2078
|
-
this.subagentStore.notifyWaiters(record);
|
|
2079
|
-
this.maybeEnqueueIngestion(record, options);
|
|
2080
|
-
},
|
|
2081
|
-
onRateLimitExhausted: (attempts) => {
|
|
2082
|
-
record.status = "failed";
|
|
2083
|
-
record.finalReason = "rate_limited_exhausted";
|
|
2084
|
-
record.error = `Provider rate limit persisted after ${attempts} attempts.`;
|
|
2085
|
-
record.updatedAt = Date.now();
|
|
2086
|
-
void this.runSubagentLifecycleHookFor(record, cwd, "SubagentStop", record.status, record.error);
|
|
2087
|
-
this.emitSubagentLifecycle(record, options, "failed", undefined, record.error);
|
|
2088
|
-
this.subagentStore.persist(record);
|
|
2089
|
-
this.subagentStore.notifyWaiters(record);
|
|
2090
|
-
this.maybeEnqueueIngestion(record, options);
|
|
2091
|
-
},
|
|
2092
|
-
onTransportRetryExhausted: (attempts) => {
|
|
2093
|
-
record.status = "failed";
|
|
2094
|
-
// failed_transient stays resumable, so the parent can still send_input
|
|
2095
|
-
// to recover the child with its context intact.
|
|
2096
|
-
record.finalReason = "failed_transient";
|
|
2097
|
-
record.error = `Provider transport error persisted after ${attempts} attempts.`;
|
|
2098
|
-
record.updatedAt = Date.now();
|
|
2099
|
-
void this.runSubagentLifecycleHookFor(record, cwd, "SubagentStop", record.status, record.error);
|
|
2100
|
-
this.emitSubagentLifecycle(record, options, "failed", undefined, record.error);
|
|
2101
|
-
this.subagentStore.persist(record);
|
|
2102
|
-
this.subagentStore.notifyWaiters(record);
|
|
2103
|
-
this.maybeEnqueueIngestion(record, options);
|
|
2104
|
-
},
|
|
2105
|
-
});
|
|
2106
|
-
}
|
|
2107
|
-
emitSubagentLifecycle(record, options, status, event, message) {
|
|
2108
|
-
const update = this.buildSubagentUpdate(record, status, event, message);
|
|
2109
|
-
options.directEmit?.(update);
|
|
2110
|
-
if (options.queueUpdates) {
|
|
2111
|
-
this.pendingSubagentUpdates.push({ id: record.parentToolCallId, name: record.parentToolName, update });
|
|
2112
|
-
this.wakeSubagentUpdateWaiters();
|
|
2113
|
-
}
|
|
2114
|
-
}
|
|
2115
|
-
/** Lets a blocked tool-execution loop drain freshly queued subagent updates. */
|
|
2116
|
-
wakeSubagentUpdateWaiters() {
|
|
2117
|
-
for (const wake of this.subagentUpdateWakers)
|
|
2118
|
-
wake();
|
|
2119
|
-
}
|
|
2120
|
-
async runSubagentLifecycleHookFor(record, cwd, eventName, status, error, abortSignal) {
|
|
2121
|
-
try {
|
|
2122
|
-
await this.runExternalHook({
|
|
2123
|
-
eventName,
|
|
2124
|
-
cwd,
|
|
2125
|
-
runId: record.runId,
|
|
2126
|
-
target: record.profile.name,
|
|
2127
|
-
payload: {
|
|
2128
|
-
agentId: record.agentId,
|
|
2129
|
-
nickname: record.nickname,
|
|
2130
|
-
profile: record.profile.name,
|
|
2131
|
-
status,
|
|
2132
|
-
error,
|
|
2133
|
-
},
|
|
2134
|
-
}, abortSignal);
|
|
2135
|
-
}
|
|
2136
|
-
catch {
|
|
2137
|
-
// Subagent lifecycle hooks are observe-only; never fail the subagent.
|
|
2138
|
-
}
|
|
2139
|
-
}
|
|
2140
1658
|
/**
|
|
2141
1659
|
* Resolves a child's model route. Priority, highest first (design v2 §1.1):
|
|
2142
1660
|
* call-site override (model/effort) > profile.model > category > inherit parent.
|
|
2143
1661
|
* The call-site override is what lets the model say "opus for this reviewer,
|
|
2144
1662
|
* haiku for these twenty scouts" per spawn/batch member at request time.
|
|
2145
1663
|
*/
|
|
1664
|
+
/** Kept as a thin delegator: routing decisions now live in SubagentRouter. */
|
|
2146
1665
|
resolveRouteForSubagent(profile, category, override) {
|
|
2147
|
-
|
|
2148
|
-
providerId: this.providerId,
|
|
2149
|
-
model: this.apiModel,
|
|
2150
|
-
thinkingLevel: this.thinkingLevel,
|
|
2151
|
-
};
|
|
2152
|
-
const snapshot = this.currentRoutingSnapshot();
|
|
2153
|
-
const resolved = resolveSubagentRoute(category ?? profile.category, { ...parentRoute }, this.agentCategories, snapshot ? tierContextFromSnapshot(snapshot, this.agentRouting) : undefined);
|
|
2154
|
-
if ("error" in resolved) {
|
|
2155
|
-
throw new Error(resolved.error);
|
|
2156
|
-
}
|
|
2157
|
-
let route = resolved.route;
|
|
2158
|
-
// modelSource is assigned while applying the chain (design §3.4): an
|
|
2159
|
-
// explicit layer naming the parent's own model is indistinguishable from
|
|
2160
|
-
// inherit by final-value comparison, yet carries different authority.
|
|
2161
|
-
let modelSource = route.categoryModelSource ?? "inherit";
|
|
2162
|
-
if (profile.model && profile.model !== "inherit") {
|
|
2163
|
-
const model = resolveModelRoute(profile.model, parentRoute.providerId);
|
|
2164
|
-
if (model.model !== "inherit") {
|
|
2165
|
-
route = { ...route, providerId: model.providerId, model: model.model, inherited: false };
|
|
2166
|
-
modelSource = "profile";
|
|
2167
|
-
}
|
|
2168
|
-
}
|
|
2169
|
-
// Call-site override beats profile and category. Bare names resolve
|
|
2170
|
-
// against the PARENT provider per the tool contract (design §3.5) — not
|
|
2171
|
-
// against a provider that category/profile may already have switched.
|
|
2172
|
-
if (override?.model) {
|
|
2173
|
-
const model = resolveModelRoute(override.model, parentRoute.providerId);
|
|
2174
|
-
if (model.model !== "inherit") {
|
|
2175
|
-
route = { ...route, providerId: model.providerId, model: model.model, inherited: false };
|
|
2176
|
-
modelSource = "callsite";
|
|
2177
|
-
}
|
|
2178
|
-
}
|
|
2179
|
-
const supportedLevels = getAvailableThinkingLevels(route.providerId, route.model);
|
|
2180
|
-
const modelMetadata = getBuiltinModel(route.providerId, route.model);
|
|
2181
|
-
const hasTrustedEffortMetadata = !!modelMetadata
|
|
2182
|
-
&& modelMetadata.reasoningLevels.some((level) => level !== "off");
|
|
2183
|
-
if (override?.effort) {
|
|
2184
|
-
// A call-site effort is explicit user/model intent: preserve the existing
|
|
2185
|
-
// value for legacy/unknown models, and downward-clamp only when the
|
|
2186
|
-
// catalog declares real effort capabilities (for example Luna ultra -> max).
|
|
2187
|
-
route = {
|
|
2188
|
-
...route,
|
|
2189
|
-
thinkingLevel: hasTrustedEffortMetadata
|
|
2190
|
-
? normalizeThinkingLevel(override.effort, supportedLevels)
|
|
2191
|
-
: override.effort,
|
|
2192
|
-
inherited: false,
|
|
2193
|
-
};
|
|
2194
|
-
}
|
|
2195
|
-
else if (hasTrustedEffortMetadata) {
|
|
2196
|
-
const categoryThinkingLevel = route.category
|
|
2197
|
-
? mergeAgentCategories(this.agentCategories)[route.category]?.thinkingLevel
|
|
2198
|
-
: undefined;
|
|
2199
|
-
// A category's thinkingLevel is calibrated for the model the CATEGORY
|
|
2200
|
-
// resolved. When a later layer (profile/call-site) replaced the model,
|
|
2201
|
-
// that level is no longer explicit intent for the final model: keep it
|
|
2202
|
-
// only if supported, else use the final model's own default — never
|
|
2203
|
-
// downward-clamp a thinking-default model to "off" (v3.6; live case:
|
|
2204
|
-
// explore's "low" + call-site glm-5.2 [high/max/off] silently landed
|
|
2205
|
-
// on "off").
|
|
2206
|
-
const modelReplacedAfterCategory = modelSource === "profile" || modelSource === "callsite";
|
|
2207
|
-
route = {
|
|
2208
|
-
...route,
|
|
2209
|
-
thinkingLevel: categoryThinkingLevel
|
|
2210
|
-
? (modelReplacedAfterCategory && !supportedLevels.includes(route.thinkingLevel)
|
|
2211
|
-
? getDefaultThinkingLevel(route.providerId, route.model)
|
|
2212
|
-
: normalizeThinkingLevel(route.thinkingLevel, supportedLevels))
|
|
2213
|
-
: normalizeInheritedThinkingLevel(route.providerId, route.model, route.thinkingLevel),
|
|
2214
|
-
};
|
|
2215
|
-
}
|
|
2216
|
-
return {
|
|
2217
|
-
...route,
|
|
2218
|
-
modelSource,
|
|
2219
|
-
modelInherited: route.providerId === parentRoute.providerId && route.model === parentRoute.model,
|
|
2220
|
-
};
|
|
2221
|
-
}
|
|
2222
|
-
/** Live snapshot for the CURRENT parent route; undefined when no accessor is wired. */
|
|
2223
|
-
currentRoutingSnapshot() {
|
|
2224
|
-
if (!this.routingSnapshotAccessor)
|
|
2225
|
-
return undefined;
|
|
2226
|
-
try {
|
|
2227
|
-
return this.routingSnapshotAccessor({ providerId: this.providerId, model: this.apiModel });
|
|
2228
|
-
}
|
|
2229
|
-
catch {
|
|
2230
|
-
// Catalog data is an enhancement, never a spawn blocker.
|
|
2231
|
-
return undefined;
|
|
2232
|
-
}
|
|
2233
|
-
}
|
|
2234
|
-
/**
|
|
2235
|
-
* Early route validation at dispatch time (design §7). Throws with an
|
|
2236
|
-
* actionable message so the model self-corrects in the same turn. The
|
|
2237
|
-
* cross-provider lock (§7.2.1) is snapshot-independent; catalog checks
|
|
2238
|
-
* degrade silently when no snapshot is available (§7.0).
|
|
2239
|
-
*/
|
|
2240
|
-
validateRouteForDispatch(route) {
|
|
2241
|
-
const snapshot = this.currentRoutingSnapshot();
|
|
2242
|
-
const crossProvider = !!route.providerId && route.providerId !== this.providerId;
|
|
2243
|
-
if (crossProvider) {
|
|
2244
|
-
// §7.2.1 — the lock. Profile/user-category routes are standing user
|
|
2245
|
-
// authorization and always pass; only call-site routes are lockable.
|
|
2246
|
-
if (!this.agentRouting.allowCrossProvider && route.modelSource === "callsite") {
|
|
2247
|
-
throw new Error("Cross-provider routing is disabled in this session's config (agentRouting.allowCrossProvider). "
|
|
2248
|
-
+ "Use a model from the parent provider, or ask the user to unlock cross-provider routing.");
|
|
2249
|
-
}
|
|
2250
|
-
// §7.2.2 — credentials, when a snapshot is available.
|
|
2251
|
-
if (snapshot && !snapshot.runnableProviderIds.includes(route.providerId)) {
|
|
2252
|
-
throw new Error(`Provider "${route.providerId}" is not configured with active credentials. `
|
|
2253
|
-
+ `Available: ${snapshot.runnableProviderIds.join(", ") || "(none)"}.`);
|
|
2254
|
-
}
|
|
2255
|
-
// §7.2.3 amended (v3.6): the provider stays the authority — no hard
|
|
2256
|
-
// catalog rejection — but a near-match against the target provider's
|
|
2257
|
-
// local catalog is positive evidence of a mistyped id (a Grok parent
|
|
2258
|
-
// invented "openai:gpt-5.6" for gpt-5.6-sol), so soft-reject with the
|
|
2259
|
-
// correction and let the model fix it this turn. Genuinely unknown ids
|
|
2260
|
-
// (no near candidates) still pass through with a note.
|
|
2261
|
-
const targetCatalog = this.routableModelIndex?.()
|
|
2262
|
-
.filter((entry) => entry.providerId === route.providerId) ?? [];
|
|
2263
|
-
if (targetCatalog.length > 0 && !targetCatalog.some((entry) => entry.id === route.model)) {
|
|
2264
|
-
const near = nearModelMatches(route.model, targetCatalog, { mode: "truncation" });
|
|
2265
|
-
if (near.length > 0) {
|
|
2266
|
-
throw new Error(`Unknown model "${route.model}" for provider "${route.providerId}". Did you mean: ${near.join(", ")}?`);
|
|
2267
|
-
}
|
|
2268
|
-
return `model ${route.providerId}:${route.model} is not in the local catalog; the provider validates it`;
|
|
2269
|
-
}
|
|
2270
|
-
return `model ${route.providerId}:${route.model} is not locally verifiable; the provider validates it`;
|
|
2271
|
-
}
|
|
2272
|
-
// §7.1 — same-provider unknown model, resolved-provider based (never
|
|
2273
|
-
// input-syntax based; qualified same-provider ids land here too).
|
|
2274
|
-
if (snapshot && route.modelSource === "callsite") {
|
|
2275
|
-
const known = snapshot.models.some((model) => model.id === route.model);
|
|
2276
|
-
if (!known) {
|
|
2277
|
-
if (snapshot.authoritative) {
|
|
2278
|
-
const available = snapshot.models
|
|
2279
|
-
.map((model) => (model.tier ? `${model.id} (${model.tier})` : model.id))
|
|
2280
|
-
.join(", ");
|
|
2281
|
-
throw new Error(`Unknown model "${route.model}" for provider "${this.providerId}". Available: ${available}.`);
|
|
2282
|
-
}
|
|
2283
|
-
const near = nearModelMatches(route.model, snapshot.models.map((model) => ({ providerId: this.providerId, id: model.id, name: model.name })));
|
|
2284
|
-
return near.length > 0
|
|
2285
|
-
? `model id "${route.model}" is unrecognized locally (did you mean: ${near.join(", ")}?); the provider validates it`
|
|
2286
|
-
: `model id "${route.model}" is unrecognized locally; the provider validates it`;
|
|
2287
|
-
}
|
|
2288
|
-
}
|
|
2289
|
-
return undefined;
|
|
1666
|
+
return this.router.resolve(profile, category, override);
|
|
2290
1667
|
}
|
|
2291
1668
|
/** Routable catalog across runnable providers (design v3.6); undefined when unwired. */
|
|
2292
1669
|
listRoutableModels() {
|
|
2293
|
-
|
|
2294
|
-
return this.routableModelIndex?.();
|
|
2295
|
-
}
|
|
2296
|
-
catch {
|
|
2297
|
-
return undefined;
|
|
2298
|
-
}
|
|
1670
|
+
return this.router.listRoutableModels();
|
|
2299
1671
|
}
|
|
2300
1672
|
/**
|
|
2301
|
-
*
|
|
2302
|
-
*
|
|
2303
|
-
* parent
|
|
2304
|
-
*
|
|
1673
|
+
* The ONE place a child Agent is constructed. The runtime hands over what it
|
|
1674
|
+
* resolved (provider/route/tools/prompt); everything inherited from the
|
|
1675
|
+
* parent is filled in here, which is also what keeps the deliberately
|
|
1676
|
+
* ABSENT arguments absent — `onMessageAppend` (a child must not append to
|
|
1677
|
+
* the parent's session log), `sessionID` (a child must not stop the
|
|
1678
|
+
* parent's auto servers) and the routing catalog (children route by
|
|
1679
|
+
* default, not from the parent's menu).
|
|
2305
1680
|
*/
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
if (snapshot?.parent.tier !== "strong")
|
|
2313
|
-
return;
|
|
2314
|
-
this.defaultedRoutingStreak++;
|
|
2315
|
-
if (this.defaultedRoutingStreak >= ROUTING_REMINDER_THRESHOLD) {
|
|
2316
|
-
this.routingReminderFired = true;
|
|
2317
|
-
this.pendingRoutingReminder = [
|
|
2318
|
-
`Routing note: ${this.defaultedRoutingStreak} children in this fan-out defaulted to the parent's`,
|
|
2319
|
-
"strong-tier model (no model/category given). If any of these tasks are mechanical",
|
|
2320
|
-
"(scan / summarize / search / extract), route them with category \"quick\"/\"explore\" or a",
|
|
2321
|
-
"fast-tier model next time. If they genuinely need this model, ignore this note.",
|
|
2322
|
-
].join(" ");
|
|
2323
|
-
}
|
|
2324
|
-
}
|
|
2325
|
-
/** Consumed by the turn hooks; also closes the counting window (§6). */
|
|
2326
|
-
consumePendingRoutingReminder() {
|
|
2327
|
-
const reminder = this.pendingRoutingReminder;
|
|
2328
|
-
this.pendingRoutingReminder = undefined;
|
|
2329
|
-
if (!this.routingReminderFired)
|
|
2330
|
-
this.defaultedRoutingStreak = 0;
|
|
2331
|
-
return reminder;
|
|
2332
|
-
}
|
|
2333
|
-
createSubagentThreadRecord(options) {
|
|
2334
|
-
const now = Date.now();
|
|
2335
|
-
const nickname = options.nickname ?? assignAgentNickname(options.profile, this.activeSubagentNicknames());
|
|
2336
|
-
return {
|
|
2337
|
-
agentId: options.agentId ?? randomUUID(),
|
|
2338
|
-
runId: options.runId ?? randomUUID(),
|
|
2339
|
-
nickname,
|
|
2340
|
-
profile: options.profile,
|
|
2341
|
-
category: options.route?.category,
|
|
2342
|
-
route: options.route,
|
|
2343
|
-
workflowInternal: options.workflowInternal,
|
|
2344
|
-
parentToolCallId: options.parentToolCallId,
|
|
2345
|
-
parentToolName: options.parentToolName,
|
|
2346
|
-
status: "queued",
|
|
2347
|
-
task: options.task,
|
|
2348
|
-
summary: "",
|
|
2349
|
-
toolNotes: [],
|
|
2350
|
-
createdAt: now,
|
|
2351
|
-
updatedAt: now,
|
|
2352
|
-
abortController: new AbortController(),
|
|
2353
|
-
waiters: new Set(),
|
|
2354
|
-
};
|
|
2355
|
-
}
|
|
2356
|
-
runSubagentThread(record, input, cwd, options) {
|
|
2357
|
-
return this.childRunner.run(record, input, cwd, options);
|
|
2358
|
-
}
|
|
2359
|
-
async createSubAgentInstance(record, tools, cwd, forkContext) {
|
|
2360
|
-
let childCwd = cwd;
|
|
2361
|
-
let childMode = "plan";
|
|
2362
|
-
if (record.profile.mode === "write_worktree") {
|
|
2363
|
-
// Write children work in a runtime-allocated worktree with fresh tool
|
|
2364
|
-
// instances bound to it (design §8): the parent tree is never touched,
|
|
2365
|
-
// and the tools' own workspace fence enforces containment in code.
|
|
2366
|
-
if (!record.worktree) {
|
|
2367
|
-
record.worktree = createSubagentWorktree(cwd, record.agentId);
|
|
2368
|
-
}
|
|
2369
|
-
childCwd = record.worktree.path;
|
|
2370
|
-
childMode = "default";
|
|
2371
|
-
tools = createWorktreeChildTools(childCwd, record.profile.tools.include);
|
|
2372
|
-
}
|
|
2373
|
-
else {
|
|
2374
|
-
// Readonly children share the parent's tool instances; isolate the only
|
|
2375
|
-
// one with mutable file state (read → its FileStateTracker) so concurrent
|
|
2376
|
-
// fan-out members never race shared tool state (design v2 §2).
|
|
2377
|
-
tools = isolateReadonlyChildFileTools(tools);
|
|
2378
|
-
}
|
|
2379
|
-
const childToolNames = tools.map((tool) => tool.name);
|
|
2380
|
-
const route = record.route ?? {
|
|
2381
|
-
providerId: this.providerId,
|
|
2382
|
-
model: this.apiModel,
|
|
2383
|
-
thinkingLevel: this.thinkingLevel,
|
|
2384
|
-
inherited: true,
|
|
2385
|
-
};
|
|
2386
|
-
const provider = await this.resolveProviderForRoute(route);
|
|
2387
|
-
const childSystemPrompt = buildSystemPrompt({
|
|
2388
|
-
agentName: "Bubble",
|
|
2389
|
-
configuredProvider: route.providerId || "none",
|
|
2390
|
-
configuredModel: route.model || "none",
|
|
2391
|
-
configuredModelId: route.providerId && route.model ? `${route.providerId}:${route.model}` : route.model || "none",
|
|
2392
|
-
thinkingLevel: route.thinkingLevel,
|
|
2393
|
-
mode: childMode,
|
|
2394
|
-
workingDir: childCwd,
|
|
2395
|
-
...buildToolPromptOptions(tools),
|
|
2396
|
-
memoryPrompt: childToolNames.some((name) => name === "memory")
|
|
2397
|
-
? this.memoryPrompt
|
|
2398
|
-
: undefined,
|
|
2399
|
-
agentProfilePrompt: [
|
|
2400
|
-
`You are subagent ${record.nickname}. Your agent profile is ${record.profile.name}.`,
|
|
2401
|
-
record.profile.mode === "write_worktree"
|
|
2402
|
-
? [
|
|
2403
|
-
"You work inside an isolated git worktree; the parent reviews your diff after you finish.",
|
|
2404
|
-
"Make your changes, verify them (run tests where possible), and end with a handoff that lists the files you changed and how you verified them.",
|
|
2405
|
-
"Do not commit, push, or touch anything outside this worktree.",
|
|
2406
|
-
].join(" ")
|
|
2407
|
-
: "",
|
|
2408
|
-
record.profile.prompt,
|
|
2409
|
-
].filter(Boolean).join("\n\n"),
|
|
2410
|
-
});
|
|
2411
|
-
const subAgent = new Agent({
|
|
2412
|
-
provider,
|
|
2413
|
-
providerId: route.providerId,
|
|
2414
|
-
model: route.model,
|
|
2415
|
-
tools,
|
|
1681
|
+
createChildAgent(spec) {
|
|
1682
|
+
const child = new Agent({
|
|
1683
|
+
provider: spec.provider,
|
|
1684
|
+
providerId: spec.providerId,
|
|
1685
|
+
model: spec.model,
|
|
1686
|
+
tools: spec.tools,
|
|
2416
1687
|
temperature: this.temperature,
|
|
2417
|
-
thinkingLevel:
|
|
2418
|
-
mode:
|
|
2419
|
-
maxTurns:
|
|
1688
|
+
thinkingLevel: spec.thinkingLevel,
|
|
1689
|
+
mode: spec.mode,
|
|
1690
|
+
maxTurns: spec.maxTurns,
|
|
2420
1691
|
budgetLedger: this.budgetLedger,
|
|
2421
|
-
budgetSource:
|
|
2422
|
-
systemPrompt:
|
|
1692
|
+
budgetSource: spec.budgetSource,
|
|
1693
|
+
systemPrompt: spec.systemPrompt,
|
|
2423
1694
|
hooks: this.hookDefinitions,
|
|
2424
1695
|
externalHooks: this.externalHooks,
|
|
2425
1696
|
agentRole: "subagent",
|
|
2426
|
-
subAgentId:
|
|
1697
|
+
subAgentId: spec.subAgentId,
|
|
2427
1698
|
agentCategories: this.agentCategories,
|
|
2428
1699
|
providerFactory: this.providerFactory,
|
|
2429
1700
|
// The scheduler owns 429 backoff for children; the transport must not
|
|
2430
1701
|
// stack its own retries on top (design §4.5).
|
|
2431
1702
|
rateLimitPolicy: "defer",
|
|
2432
1703
|
});
|
|
2433
|
-
if (
|
|
2434
|
-
|
|
2435
|
-
// persisted history — including its original system prompt — so
|
|
2436
|
-
// send_input continues with context intact.
|
|
2437
|
-
subAgent.messages = record.messages.map((message) => ({ ...message }));
|
|
2438
|
-
record.messages = undefined;
|
|
1704
|
+
if (spec.resumeMessages && spec.resumeMessages.length > 0) {
|
|
1705
|
+
child.messages = spec.resumeMessages;
|
|
2439
1706
|
}
|
|
2440
|
-
else if (forkContext) {
|
|
2441
|
-
|
|
1707
|
+
else if (spec.forkContext) {
|
|
1708
|
+
child.messages = this.forkMessagesForSubagent(spec.systemPrompt);
|
|
2442
1709
|
}
|
|
2443
|
-
return
|
|
2444
|
-
}
|
|
2445
|
-
async resolveProviderForRoute(route) {
|
|
2446
|
-
if (!route.providerId || route.providerId === this.providerId) {
|
|
2447
|
-
return this.provider;
|
|
2448
|
-
}
|
|
2449
|
-
if (!this.providerFactory) {
|
|
2450
|
-
throw new Error([
|
|
2451
|
-
`Subagent route requires provider "${route.providerId}" for model "${route.model}",`,
|
|
2452
|
-
`but the parent agent only has provider "${this.providerId || "none"}" and no provider factory is configured.`,
|
|
2453
|
-
].join(" "));
|
|
2454
|
-
}
|
|
2455
|
-
return this.providerFactory(route);
|
|
1710
|
+
return child;
|
|
2456
1711
|
}
|
|
2457
1712
|
forkMessagesForSubagent(childSystemPrompt) {
|
|
2458
1713
|
const forked = this.messages
|
|
@@ -2470,74 +1725,6 @@ export class Agent {
|
|
|
2470
1725
|
.slice(-20);
|
|
2471
1726
|
return [{ role: "system", content: childSystemPrompt }, ...forked];
|
|
2472
1727
|
}
|
|
2473
|
-
buildSubagentUpdate(record, status, event, message) {
|
|
2474
|
-
return {
|
|
2475
|
-
type: "subagent_update",
|
|
2476
|
-
parentToolCallId: record.parentToolCallId,
|
|
2477
|
-
runId: record.runId,
|
|
2478
|
-
subAgentId: record.agentId,
|
|
2479
|
-
agentName: record.profile.name,
|
|
2480
|
-
nickname: record.nickname,
|
|
2481
|
-
category: record.category,
|
|
2482
|
-
route: record.route,
|
|
2483
|
-
status,
|
|
2484
|
-
childEvent: event,
|
|
2485
|
-
summaryDelta: event?.type === "text_delta" ? event.content : undefined,
|
|
2486
|
-
toolName: "name" in (event ?? {}) ? event.name : undefined,
|
|
2487
|
-
toolCallId: "id" in (event ?? {}) ? event.id : undefined,
|
|
2488
|
-
message,
|
|
2489
|
-
metadata: {
|
|
2490
|
-
kind: "subagent",
|
|
2491
|
-
runId: record.runId,
|
|
2492
|
-
subagents: [{
|
|
2493
|
-
subAgentId: record.agentId,
|
|
2494
|
-
agentName: record.profile.name,
|
|
2495
|
-
nickname: record.nickname,
|
|
2496
|
-
category: record.category,
|
|
2497
|
-
route: record.route,
|
|
2498
|
-
status,
|
|
2499
|
-
profileSource: record.profile.source,
|
|
2500
|
-
task: record.task,
|
|
2501
|
-
summary: record.summary,
|
|
2502
|
-
toolNotes: record.toolNotes,
|
|
2503
|
-
usage: record.usage,
|
|
2504
|
-
error: record.error,
|
|
2505
|
-
}],
|
|
2506
|
-
},
|
|
2507
|
-
};
|
|
2508
|
-
}
|
|
2509
|
-
queueSubagentUpdate(record, status, event, message) {
|
|
2510
|
-
this.pendingSubagentUpdates.push({
|
|
2511
|
-
id: record.parentToolCallId,
|
|
2512
|
-
name: record.parentToolName,
|
|
2513
|
-
update: this.buildSubagentUpdate(record, status, event, message),
|
|
2514
|
-
});
|
|
2515
|
-
this.wakeSubagentUpdateWaiters();
|
|
2516
|
-
}
|
|
2517
|
-
drainSubagentToolUpdates() {
|
|
2518
|
-
return this.pendingSubagentUpdates.splice(0, this.pendingSubagentUpdates.length)
|
|
2519
|
-
.map((pending) => ({
|
|
2520
|
-
type: "tool_update",
|
|
2521
|
-
id: pending.id,
|
|
2522
|
-
name: pending.name,
|
|
2523
|
-
update: pending.update,
|
|
2524
|
-
}));
|
|
2525
|
-
}
|
|
2526
|
-
activeSubagentNicknames() {
|
|
2527
|
-
return this.subagentStore.active().map((record) => record.nickname);
|
|
2528
|
-
}
|
|
2529
|
-
resolveSubagentTargets(agentIds) {
|
|
2530
|
-
if (!agentIds || agentIds.length === 0) {
|
|
2531
|
-
return this.subagentStore.values().filter((record) => record.status !== "closed" && !record.workflowInternal);
|
|
2532
|
-
}
|
|
2533
|
-
return agentIds.map((id) => {
|
|
2534
|
-
const record = this.subagentStore.get(id);
|
|
2535
|
-
if (!record) {
|
|
2536
|
-
throw new Error(`Unknown subagent: ${id}`);
|
|
2537
|
-
}
|
|
2538
|
-
return record;
|
|
2539
|
-
});
|
|
2540
|
-
}
|
|
2541
1728
|
maybeCompactResidentHistory() {
|
|
2542
1729
|
if (this.messages.length === 0) {
|
|
2543
1730
|
return;
|
|
@@ -2550,24 +1737,28 @@ export class Agent {
|
|
|
2550
1737
|
? getContextBudget(this.providerId, this.apiModel, candidate)
|
|
2551
1738
|
: undefined;
|
|
2552
1739
|
const heapUsed = getCurrentHeapUsed();
|
|
2553
|
-
const residentChars = estimateResidentChars(candidate);
|
|
2554
1740
|
const keepRecentTurns = countUserTurns(candidate) > 10
|
|
2555
1741
|
? 2
|
|
2556
1742
|
: RESIDENT_HISTORY_KEEP_RECENT_TURNS;
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
1743
|
+
// Cliff-edge only (docs/harness-thinning.md, wave 2): history is the
|
|
1744
|
+
// model's working memory and is NEVER trimmed early. The message-count
|
|
1745
|
+
// and char-size triggers that used to fire here mid-run created re-read
|
|
1746
|
+
// loops - the model lost what it had read and read it again. Compaction
|
|
1747
|
+
// now waits for the context-window budget itself; the heap guard stays
|
|
1748
|
+
// as process life-support, not policy.
|
|
1749
|
+
const shouldAggressivelyPrune = heapUsed >= RESIDENT_HISTORY_HEAP_HARD_LIMIT;
|
|
1750
|
+
const shouldCompact = !!budget?.shouldCompact;
|
|
2562
1751
|
if (shouldAggressivelyPrune) {
|
|
2563
1752
|
candidate = aggressivePruneMessages(candidate);
|
|
2564
1753
|
}
|
|
2565
1754
|
let compactedPath;
|
|
1755
|
+
let residentSummary;
|
|
2566
1756
|
if (shouldCompact) {
|
|
2567
1757
|
const compacted = compactMessages(candidate, { keepRecentTurns });
|
|
2568
1758
|
if (compacted.compacted && compacted.messages) {
|
|
2569
1759
|
candidate = compacted.messages;
|
|
2570
1760
|
compactedPath = "resident";
|
|
1761
|
+
residentSummary = compacted.summary;
|
|
2571
1762
|
traceEvent("compaction_fired", { path: "resident", droppedEntries: compacted.droppedEntries });
|
|
2572
1763
|
}
|
|
2573
1764
|
else {
|
|
@@ -2595,12 +1786,28 @@ export class Agent {
|
|
|
2595
1786
|
this.lastInputTokens = null;
|
|
2596
1787
|
this.lastAnchorMessageCount = null;
|
|
2597
1788
|
this.fileStateTracker?.invalidateReadHistory();
|
|
2598
|
-
if (compactedPath === "resident")
|
|
1789
|
+
if (compactedPath === "resident") {
|
|
2599
1790
|
this.compactionStats.resident += 1;
|
|
1791
|
+
this.persistCompactionSummary(residentSummary);
|
|
1792
|
+
}
|
|
2600
1793
|
if (compactedPath === "subturn")
|
|
2601
1794
|
this.compactionStats.subturn += 1;
|
|
2602
1795
|
}
|
|
2603
1796
|
}
|
|
1797
|
+
/**
|
|
1798
|
+
* Hand a multi-turn compaction summary to the host for session persistence.
|
|
1799
|
+
* Failure is swallowed: losing persistence must never break the run itself.
|
|
1800
|
+
*/
|
|
1801
|
+
persistCompactionSummary(summary) {
|
|
1802
|
+
if (!summary?.trim())
|
|
1803
|
+
return;
|
|
1804
|
+
try {
|
|
1805
|
+
this.onCompactionApplied?.(summary);
|
|
1806
|
+
}
|
|
1807
|
+
catch {
|
|
1808
|
+
// ignore — persistence is best-effort
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
2604
1811
|
appendMessage(message) {
|
|
2605
1812
|
if (message.role === "assistant" && message.content) {
|
|
2606
1813
|
message.content = sanitizeInternalReminderBlocks(message.content);
|
|
@@ -2920,10 +2127,18 @@ function cancelledToolResult(toolName) {
|
|
|
2920
2127
|
metadata: { reason: "cancelled" },
|
|
2921
2128
|
};
|
|
2922
2129
|
}
|
|
2923
|
-
|
|
2130
|
+
/** Exported for tests: the wake latch below is easiest to pin directly. */
|
|
2131
|
+
export function createUpdateQueue() {
|
|
2924
2132
|
const items = [];
|
|
2925
2133
|
let waiter;
|
|
2926
2134
|
let abortCleanup;
|
|
2135
|
+
// Latch for wakes that arrive with no parked waiter — a wake() fired while
|
|
2136
|
+
// the consuming generator is suspended at a yield would otherwise be lost.
|
|
2137
|
+
// Callers today each pair their wake with a synchronously-checked flag
|
|
2138
|
+
// (items / settled / pendingSubagentUpdates), so no wake is currently
|
|
2139
|
+
// dropped; the latch keeps that correctness inside the queue instead of
|
|
2140
|
+
// depending on every future caller repeating the pairing.
|
|
2141
|
+
let signaled = false;
|
|
2927
2142
|
return {
|
|
2928
2143
|
push(item) {
|
|
2929
2144
|
items.push(item);
|
|
@@ -2938,6 +2153,10 @@ function createUpdateQueue() {
|
|
|
2938
2153
|
wait(signal) {
|
|
2939
2154
|
if (items.length > 0)
|
|
2940
2155
|
return Promise.resolve("woken");
|
|
2156
|
+
if (signaled) {
|
|
2157
|
+
signaled = false;
|
|
2158
|
+
return Promise.resolve("woken");
|
|
2159
|
+
}
|
|
2941
2160
|
if (signal?.aborted)
|
|
2942
2161
|
return Promise.resolve("aborted");
|
|
2943
2162
|
return new Promise((resolve) => {
|
|
@@ -2961,10 +2180,14 @@ function createUpdateQueue() {
|
|
|
2961
2180
|
},
|
|
2962
2181
|
wake() {
|
|
2963
2182
|
const resolve = waiter;
|
|
2183
|
+
if (!resolve) {
|
|
2184
|
+
signaled = true;
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2964
2187
|
waiter = undefined;
|
|
2965
2188
|
abortCleanup?.();
|
|
2966
2189
|
abortCleanup = undefined;
|
|
2967
|
-
resolve
|
|
2190
|
+
resolve("woken");
|
|
2968
2191
|
},
|
|
2969
2192
|
};
|
|
2970
2193
|
}
|
|
@@ -2990,35 +2213,6 @@ function getCurrentHeapUsed() {
|
|
|
2990
2213
|
return 0;
|
|
2991
2214
|
}
|
|
2992
2215
|
}
|
|
2993
|
-
function isFinalSubagentStatus(status) {
|
|
2994
|
-
return status === "completed"
|
|
2995
|
-
|| status === "failed"
|
|
2996
|
-
|| status === "blocked"
|
|
2997
|
-
|| status === "cancelled"
|
|
2998
|
-
|| status === "closed";
|
|
2999
|
-
}
|
|
3000
|
-
/**
|
|
3001
|
-
* Persists the full rendered workflow result so the inline preview can stay
|
|
3002
|
-
* bounded without losing data (the parent reads the file selectively when the
|
|
3003
|
-
* preview was cut). A write failure only costs the pointer, never the run.
|
|
3004
|
-
*/
|
|
3005
|
-
function persistWorkflowResult(runId, value) {
|
|
3006
|
-
try {
|
|
3007
|
-
const dir = join(getBubbleHome(), "workflows");
|
|
3008
|
-
mkdirSync(dir, { recursive: true });
|
|
3009
|
-
const path = join(dir, `${runId}.result.txt`);
|
|
3010
|
-
writeFileSync(path, renderWorkflowResultValue(value));
|
|
3011
|
-
return path;
|
|
3012
|
-
}
|
|
3013
|
-
catch {
|
|
3014
|
-
return undefined;
|
|
3015
|
-
}
|
|
3016
|
-
}
|
|
3017
|
-
function normalizeWaitTimeout(value) {
|
|
3018
|
-
if (typeof value !== "number" || !Number.isFinite(value))
|
|
3019
|
-
return 30_000;
|
|
3020
|
-
return Math.max(100, Math.min(3_600_000, Math.floor(value)));
|
|
3021
|
-
}
|
|
3022
2216
|
function isSubagentLifecycleTool(name) {
|
|
3023
2217
|
return name === "subagent"
|
|
3024
2218
|
|| name === "spawn_agent"
|