specrails-core 6.4.0 → 6.6.0
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-runtime/capabilities.d.ts +2 -2
- package/dist/agent-runtime/capabilities.js +22 -5
- package/dist/agent-runtime/cli-executor.js +8 -3
- package/dist/agent-runtime/cli.d.ts +4 -4
- package/dist/agent-runtime/cli.js +50 -3
- package/dist/agent-runtime/compact/environment.d.ts +50 -4
- package/dist/agent-runtime/compact/environment.js +183 -6
- package/dist/agent-runtime/compact/step.d.ts +1 -1
- package/dist/agent-runtime/efficiency-summary.d.ts +1 -1
- package/dist/agent-runtime/efficiency-types.d.ts +1 -1
- package/dist/agent-runtime/engine/checkpoint/database.d.ts +15 -15
- package/dist/agent-runtime/engine/contracts.d.ts +2 -1
- package/dist/agent-runtime/engine/definition-types.d.ts +1 -1
- package/dist/agent-runtime/engine/efficiency-summary.d.ts +8 -8
- package/dist/agent-runtime/engine/fork.d.ts +1 -1
- package/dist/agent-runtime/engine/graph-description.d.ts +16 -16
- package/dist/agent-runtime/engine/implementation-compiler.d.ts +68 -68
- package/dist/agent-runtime/engine/piece-dependencies.js +4 -4
- package/dist/agent-runtime/engine/pieces/implementation-step.d.ts +1 -1
- package/dist/agent-runtime/engine/pieces/role-turn.d.ts +5 -0
- package/dist/agent-runtime/engine/pieces/role-turn.js +64 -22
- package/dist/agent-runtime/engine/pieces/shared.d.ts +2 -2
- package/dist/agent-runtime/engine/pieces/verify.js +46 -13
- package/dist/agent-runtime/engine/run-status.d.ts +165 -165
- package/dist/agent-runtime/engine/runs.d.ts +168 -168
- package/dist/agent-runtime/engine/state.d.ts +16 -16
- package/dist/agent-runtime/evaluation.d.ts +3 -3
- package/dist/agent-runtime/executor-types.d.ts +10 -10
- package/dist/agent-runtime/graph/convergence.d.ts +2 -2
- package/dist/agent-runtime/graph/convergence.js +11 -0
- package/dist/agent-runtime/graph/nodes.js +35 -4
- package/dist/agent-runtime/graph/review-policy.d.ts +1 -1
- package/dist/agent-runtime/graph/state.d.ts +6 -2
- package/dist/agent-runtime/guardrails.d.ts +1 -1
- package/dist/agent-runtime/guardrails.js +4 -1
- package/dist/agent-runtime/index.d.ts +3 -2
- package/dist/agent-runtime/index.js +1 -0
- package/dist/agent-runtime/openai-executor.d.ts +9 -9
- package/dist/agent-runtime/openspec.d.ts +4 -4
- package/dist/agent-runtime/prompts.d.ts +6 -0
- package/dist/agent-runtime/prompts.js +32 -8
- package/dist/agent-runtime/recovery.d.ts +12 -12
- package/dist/agent-runtime/session/application/active-session.d.ts +176 -0
- package/dist/agent-runtime/session/application/active-session.js +701 -0
- package/dist/agent-runtime/session/application/delegation.d.ts +73 -0
- package/dist/agent-runtime/session/application/delegation.js +171 -0
- package/dist/agent-runtime/session/application/output-buffer.d.ts +43 -0
- package/dist/agent-runtime/session/application/output-buffer.js +65 -0
- package/dist/agent-runtime/session/application/session-service.d.ts +121 -0
- package/dist/agent-runtime/session/application/session-service.js +256 -0
- package/dist/agent-runtime/session/application/timers.d.ts +15 -0
- package/dist/agent-runtime/session/application/timers.js +35 -0
- package/dist/agent-runtime/session/domain/descriptor.d.ts +6 -0
- package/dist/agent-runtime/session/domain/descriptor.js +32 -0
- package/dist/agent-runtime/session/domain/errors.d.ts +9 -0
- package/dist/agent-runtime/session/domain/errors.js +16 -0
- package/dist/agent-runtime/session/domain/events.d.ts +147 -0
- package/dist/agent-runtime/session/domain/events.js +24 -0
- package/dist/agent-runtime/session/domain/fingerprint.d.ts +2 -0
- package/dist/agent-runtime/session/domain/fingerprint.js +10 -0
- package/dist/agent-runtime/session/domain/interruption.d.ts +13 -0
- package/dist/agent-runtime/session/domain/interruption.js +39 -0
- package/dist/agent-runtime/session/domain/policy.d.ts +42 -0
- package/dist/agent-runtime/session/domain/policy.js +104 -0
- package/dist/agent-runtime/session/domain/snapshot.d.ts +100 -0
- package/dist/agent-runtime/session/domain/snapshot.js +234 -0
- package/dist/agent-runtime/session/domain/state-machines.d.ts +21 -0
- package/dist/agent-runtime/session/domain/state-machines.js +41 -0
- package/dist/agent-runtime/session/domain/types.d.ts +130 -0
- package/dist/agent-runtime/session/domain/types.js +15 -0
- package/dist/agent-runtime/session/domain/usage.d.ts +54 -0
- package/dist/agent-runtime/session/domain/usage.js +86 -0
- package/dist/agent-runtime/session/drivers/claude/argv.d.ts +19 -0
- package/dist/agent-runtime/session/drivers/claude/argv.js +67 -0
- package/dist/agent-runtime/session/drivers/claude/driver.d.ts +19 -0
- package/dist/agent-runtime/session/drivers/claude/driver.js +134 -0
- package/dist/agent-runtime/session/drivers/claude/translator.d.ts +40 -0
- package/dist/agent-runtime/session/drivers/claude/translator.js +247 -0
- package/dist/agent-runtime/session/drivers/codex/argv.d.ts +12 -0
- package/dist/agent-runtime/session/drivers/codex/argv.js +76 -0
- package/dist/agent-runtime/session/drivers/codex/driver.d.ts +19 -0
- package/dist/agent-runtime/session/drivers/codex/driver.js +193 -0
- package/dist/agent-runtime/session/drivers/codex/rpc.d.ts +28 -0
- package/dist/agent-runtime/session/drivers/codex/rpc.js +81 -0
- package/dist/agent-runtime/session/drivers/codex/translator.d.ts +53 -0
- package/dist/agent-runtime/session/drivers/codex/translator.js +267 -0
- package/dist/agent-runtime/session/drivers/common/process.d.ts +37 -0
- package/dist/agent-runtime/session/drivers/common/process.js +116 -0
- package/dist/agent-runtime/session/drivers/executor/driver.d.ts +22 -0
- package/dist/agent-runtime/session/drivers/executor/driver.js +163 -0
- package/dist/agent-runtime/session/drivers/registry.d.ts +32 -0
- package/dist/agent-runtime/session/drivers/registry.js +38 -0
- package/dist/agent-runtime/session/host/protocol.d.ts +18 -0
- package/dist/agent-runtime/session/host/protocol.js +88 -0
- package/dist/agent-runtime/session/host/server.d.ts +57 -0
- package/dist/agent-runtime/session/host/server.js +229 -0
- package/dist/agent-runtime/session/index.d.ts +45 -0
- package/dist/agent-runtime/session/index.js +74 -0
- package/dist/agent-runtime/session/journal/schema.d.ts +7 -0
- package/dist/agent-runtime/session/journal/schema.js +44 -0
- package/dist/agent-runtime/session/journal/sqlite-journal.d.ts +70 -0
- package/dist/agent-runtime/session/journal/sqlite-journal.js +279 -0
- package/dist/agent-runtime/session/ports.d.ts +192 -0
- package/dist/agent-runtime/session/ports.js +1 -0
- package/dist/agent-runtime/session/testing/driver-conformance.d.ts +21 -0
- package/dist/agent-runtime/session/testing/driver-conformance.js +97 -0
- package/dist/agent-runtime/session/testing/fake-clock.d.ts +20 -0
- package/dist/agent-runtime/session/testing/fake-clock.js +34 -0
- package/dist/agent-runtime/session/testing/fixture-replayer.d.ts +41 -0
- package/dist/agent-runtime/session/testing/fixture-replayer.js +172 -0
- package/dist/agent-runtime/session/testing/index.d.ts +9 -0
- package/dist/agent-runtime/session/testing/index.js +9 -0
- package/dist/agent-runtime/session/testing/memory-journal.d.ts +27 -0
- package/dist/agent-runtime/session/testing/memory-journal.js +70 -0
- package/dist/agent-runtime/session/testing/scripted-driver.d.ts +30 -0
- package/dist/agent-runtime/session/testing/scripted-driver.js +77 -0
- package/dist/agent-runtime/verification-proposals.d.ts +9 -0
- package/dist/agent-runtime/verification-proposals.js +69 -0
- package/dist/agent-runtime/verification-repair.d.ts +31 -2
- package/dist/agent-runtime/verification-repair.js +58 -3
- package/dist/installer/commands/init.d.ts +1 -1
- package/dist/installer/phases/install-config.js +10 -3
- package/dist/installer/util/paths.d.ts +25 -1
- package/dist/installer/util/registry.js +3 -3
- package/dist/pipeline/pipeline-state.d.ts +42 -11
- package/dist/pipeline/pipeline-state.js +47 -8
- package/dist/shared/specrails-home.d.ts +21 -0
- package/dist/shared/specrails-home.js +36 -0
- package/integration-contract.json +42 -1
- package/package.json +23 -11
- package/schemas/agent-runtime.schema.json +1 -1
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
import { SessionError, isSessionError } from '../domain/errors.js';
|
|
2
|
+
import { fingerprint } from '../domain/fingerprint.js';
|
|
3
|
+
import { buildInterruptionNotice } from '../domain/interruption.js';
|
|
4
|
+
import { nativeSubagentsAllowed, subagentsActive } from '../domain/policy.js';
|
|
5
|
+
import { applyEvent, liveSubagents } from '../domain/snapshot.js';
|
|
6
|
+
import { EMPTY_USAGE } from '../domain/types.js';
|
|
7
|
+
import { FRESH_BASELINE, UNKNOWN_BASELINE, computeTurnUsage, withEstimatedCost } from '../domain/usage.js';
|
|
8
|
+
import { OutputBuffer } from './output-buffer.js';
|
|
9
|
+
import { SessionTimers } from './timers.js';
|
|
10
|
+
/** Delegated results the parent never saw: the agent receives them in a system turn. */
|
|
11
|
+
function delegatedResultsPrompt(results) {
|
|
12
|
+
return [
|
|
13
|
+
'[Specrails session] Sub-agents you delegated have finished. Their results:',
|
|
14
|
+
...results.map((item, index) => `\n${index + 1}. ${item.description} (${item.status})\n${item.result}`),
|
|
15
|
+
'\nContinue the task with these results, or report back to the user.',
|
|
16
|
+
].join('\n');
|
|
17
|
+
}
|
|
18
|
+
const COLLECT_PROMPT = [
|
|
19
|
+
'[Specrails session] Your sub-agents have finished.',
|
|
20
|
+
'Collect their results (for example with your wait/agent tools), then continue the task or report back to the user.',
|
|
21
|
+
].join(' ');
|
|
22
|
+
/**
|
|
23
|
+
* Coordinates one session: turns driver facts into journaled events, owns the
|
|
24
|
+
* provider process lifecycle, phases, settlement, limits and teardown.
|
|
25
|
+
* All mutation goes through `commit`, which folds before appending, so the
|
|
26
|
+
* journal never holds an event the domain would reject.
|
|
27
|
+
*/
|
|
28
|
+
export class ActiveSession {
|
|
29
|
+
sessionId;
|
|
30
|
+
config;
|
|
31
|
+
deps;
|
|
32
|
+
state;
|
|
33
|
+
driver = null;
|
|
34
|
+
opening = null;
|
|
35
|
+
generation;
|
|
36
|
+
/** Generation whose events are accepted (the live or opening process); null when none. */
|
|
37
|
+
liveGeneration = null;
|
|
38
|
+
closing = false;
|
|
39
|
+
baseline;
|
|
40
|
+
timers;
|
|
41
|
+
output;
|
|
42
|
+
descriptor;
|
|
43
|
+
/** Inputs held back for drivers without a native input queue. */
|
|
44
|
+
heldInputs = [];
|
|
45
|
+
/** Inputs the application wrote on its own (policy-driven turns). */
|
|
46
|
+
systemInputIds = new Set();
|
|
47
|
+
pendingDeferred = null;
|
|
48
|
+
roster = null;
|
|
49
|
+
handoffs = 0;
|
|
50
|
+
finishedSinceLastTurn = new Set();
|
|
51
|
+
/** Finished delegated sub-agents whose results the agent has not received yet. */
|
|
52
|
+
delegatedResults = new Map();
|
|
53
|
+
lastUserTurnAt = null;
|
|
54
|
+
lastUsedAt;
|
|
55
|
+
constructor(sessionId, snapshot, config, deps) {
|
|
56
|
+
this.sessionId = sessionId;
|
|
57
|
+
this.config = config;
|
|
58
|
+
this.deps = deps;
|
|
59
|
+
this.state = snapshot;
|
|
60
|
+
this.descriptor = deps.factory.descriptor;
|
|
61
|
+
this.generation = snapshot.process.generation;
|
|
62
|
+
this.timers = new SessionTimers(deps.clock);
|
|
63
|
+
this.output = new OutputBuffer(deps.outputLimits);
|
|
64
|
+
this.lastUsedAt = deps.clock.now();
|
|
65
|
+
const ref = snapshot.providerSessionRef;
|
|
66
|
+
this.baseline = ref ? deps.journal.baseline(ref) ?? UNKNOWN_BASELINE : FRESH_BASELINE;
|
|
67
|
+
}
|
|
68
|
+
get processAlive() { return this.driver !== null || this.opening !== null; }
|
|
69
|
+
get isBusy() { return this.state.openTurn !== null || liveSubagents(this.state).length > 0; }
|
|
70
|
+
get policy() { return this.config.policy; }
|
|
71
|
+
// ── Use cases ─────────────────────────────────────────────────────────────
|
|
72
|
+
async send(input) {
|
|
73
|
+
this.assertOpen();
|
|
74
|
+
const existing = this.state.inputs[input.inputId];
|
|
75
|
+
if (existing) {
|
|
76
|
+
if (existing.fingerprint !== fingerprint(input.text)) {
|
|
77
|
+
throw new SessionError('input_conflict', `Input ${input.inputId} was already sent with different content`, { inputId: input.inputId });
|
|
78
|
+
}
|
|
79
|
+
return { inputId: input.inputId, state: existing.state };
|
|
80
|
+
}
|
|
81
|
+
const notice = buildInterruptionNotice(this.state);
|
|
82
|
+
this.commit([
|
|
83
|
+
{ type: 'input.accepted', inputId: input.inputId, delivery: input.delivery, text: input.text, ...(input.attachments ? { attachments: input.attachments } : {}) },
|
|
84
|
+
...(notice ? [{ type: 'notice.interruption', subagentIds: notice.subagentIds, inputIds: notice.inputIds }] : []),
|
|
85
|
+
]);
|
|
86
|
+
this.touch();
|
|
87
|
+
const text = notice ? `${notice.text}\n\n${input.text}` : input.text;
|
|
88
|
+
await this.deliver({ inputId: input.inputId, text, delivery: input.delivery, origin: 'user', ...(input.attachments ? { attachments: input.attachments } : {}) });
|
|
89
|
+
return { inputId: input.inputId, state: this.state.inputs[input.inputId]?.state ?? 'accepted' };
|
|
90
|
+
}
|
|
91
|
+
async interrupt() {
|
|
92
|
+
this.assertOpen();
|
|
93
|
+
const turn = this.state.openTurn;
|
|
94
|
+
if (!turn || !this.driver)
|
|
95
|
+
return null;
|
|
96
|
+
await this.driver.interrupt();
|
|
97
|
+
this.timers.arm('interruptGrace', this.deps.interruptGraceMs ?? 5_000, () => {
|
|
98
|
+
if (this.state.openTurn?.turnId === turn.turnId)
|
|
99
|
+
void this.retire('user_stop', { turnStatus: 'stopped', subagentPhase: 'stopped', reason: 'user_stop' });
|
|
100
|
+
});
|
|
101
|
+
return turn.turnId;
|
|
102
|
+
}
|
|
103
|
+
async stopSubagents(ids) {
|
|
104
|
+
this.assertOpen();
|
|
105
|
+
// Delegated sub-agents are stopped through their own sessions (DelegationCoordinator).
|
|
106
|
+
const live = liveSubagents(this.state).filter((node) => !node.delegated).map((node) => node.subagentId);
|
|
107
|
+
const targets = ids ? live.filter((id) => ids.includes(id)) : live;
|
|
108
|
+
if (targets.length === 0 || !this.driver)
|
|
109
|
+
return [];
|
|
110
|
+
const result = await this.driver.stopSubagents(ids ? targets : undefined);
|
|
111
|
+
if (result === 'process') {
|
|
112
|
+
await this.retire('host_request', { turnStatus: 'stopped', subagentPhase: 'stopped', reason: 'host_request' });
|
|
113
|
+
return targets;
|
|
114
|
+
}
|
|
115
|
+
for (const id of result) {
|
|
116
|
+
if (this.state.subagents[id]?.phase === 'running' || this.state.subagents[id]?.phase === 'idle') {
|
|
117
|
+
this.commit([{ type: 'subagent.phase', subagentId: id, phase: 'stopped', reason: 'host_request' }, ...this.output.drainSubagent(id)]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
this.afterSubagentChange();
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Policy says no sub-agents, yet the provider started one (a provider switch
|
|
125
|
+
* can be ignored by a model or CLI version). Stop it and say so: the policy
|
|
126
|
+
* holds whatever the provider's own switch does.
|
|
127
|
+
*/
|
|
128
|
+
async blockSubagent(subagentId) {
|
|
129
|
+
this.commit([{ type: 'provider.diagnostic', level: 'warning', code: 'policy.subagent_blocked', message: `The provider started sub-agent ${subagentId} although sub-agents are disabled; it was stopped.` }]);
|
|
130
|
+
try {
|
|
131
|
+
const result = this.driver ? await this.driver.stopSubagents([subagentId]) : [];
|
|
132
|
+
if (result === 'process') {
|
|
133
|
+
await this.retire('host_request', { turnStatus: 'stopped', subagentPhase: 'stopped', reason: 'policy' });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
this.commit([{ type: 'provider.diagnostic', level: 'warning', code: 'provider.stop_failed', message: `${subagentId}: ${error.message}` }]);
|
|
139
|
+
}
|
|
140
|
+
if (this.state.subagents[subagentId]?.phase === 'running') {
|
|
141
|
+
this.commit([{ type: 'subagent.phase', subagentId, phase: 'stopped', reason: 'policy' }, ...this.output.drainSubagent(subagentId)]);
|
|
142
|
+
this.afterSubagentChange();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Apply now when nothing is running; otherwise defer to the next idle point. */
|
|
146
|
+
async update(changes) {
|
|
147
|
+
this.assertOpen();
|
|
148
|
+
const recorded = Object.fromEntries(Object.entries(changes).filter(([, value]) => value !== undefined));
|
|
149
|
+
// Hosts re-send their whole configuration before every turn. A change that
|
|
150
|
+
// leaves the effective configuration as it is must not restart the process.
|
|
151
|
+
const effective = { ...this.config, ...(this.pendingDeferred ?? {}) };
|
|
152
|
+
if (Object.entries(recorded).every(([key, value]) => sameValue(effective[key], value)))
|
|
153
|
+
return this.pendingDeferred ? 'deferred' : 'applied';
|
|
154
|
+
if (this.isBusy) {
|
|
155
|
+
this.pendingDeferred = { ...(this.pendingDeferred ?? {}), ...recorded };
|
|
156
|
+
this.commit([{ type: 'session.updated', changes: recorded, outcome: 'deferred' }]);
|
|
157
|
+
return 'deferred';
|
|
158
|
+
}
|
|
159
|
+
await this.applyChanges(recorded);
|
|
160
|
+
return 'applied';
|
|
161
|
+
}
|
|
162
|
+
async close(reason) {
|
|
163
|
+
if (this.state.status === 'closed')
|
|
164
|
+
return;
|
|
165
|
+
await this.retire('session_closed', { turnStatus: 'stopped', subagentPhase: 'stopped', reason: 'host_request' });
|
|
166
|
+
this.commit([{ type: 'session.closed', reason }]);
|
|
167
|
+
this.timers.cancelAll();
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Record work a previous host left running as interrupted. Nothing is
|
|
171
|
+
* restarted: the next user input carries the interruption notice instead.
|
|
172
|
+
*/
|
|
173
|
+
recoverAfterHostLoss(reason) {
|
|
174
|
+
const hadProcess = this.state.process.alive;
|
|
175
|
+
// Delegated children ran in the host that is gone: they were interrupted too.
|
|
176
|
+
const delegated = this.delegatedLive();
|
|
177
|
+
if (delegated.length > 0)
|
|
178
|
+
this.commit(delegated.map((subagentId) => ({ type: 'subagent.phase', subagentId, phase: 'interrupted', reason })));
|
|
179
|
+
this.endRunningWork({ turnStatus: 'interrupted', subagentPhase: 'interrupted', reason });
|
|
180
|
+
if (hadProcess)
|
|
181
|
+
this.commit([{ type: 'session.process', state: 'exited', generation: this.state.process.generation, reason, exitCode: null }]);
|
|
182
|
+
}
|
|
183
|
+
/** Retire the provider process; running work ends as described by `teardown`. */
|
|
184
|
+
async retire(reason, teardown) {
|
|
185
|
+
this.timers.cancel('idle', 'stall', 'backgroundMax', 'turnInactivity', 'settle', 'interruptGrace');
|
|
186
|
+
const driver = this.driver ?? (this.opening ? await this.opening.catch(() => null) : null);
|
|
187
|
+
if (!driver)
|
|
188
|
+
return;
|
|
189
|
+
this.closing = true;
|
|
190
|
+
try {
|
|
191
|
+
await driver.close(reason);
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
this.closing = false;
|
|
195
|
+
this.driver = null;
|
|
196
|
+
this.liveGeneration = null;
|
|
197
|
+
this.endRunningWork(teardown);
|
|
198
|
+
this.commit([{ type: 'session.process', state: 'retired', generation: this.generation, reason }]);
|
|
199
|
+
this.deps.touched(this);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// ── Delivery and process lifecycle ────────────────────────────────────────
|
|
203
|
+
async deliver(input) {
|
|
204
|
+
const caps = this.descriptor.capabilities;
|
|
205
|
+
const turnRunning = this.state.openTurn !== null;
|
|
206
|
+
if (!caps.nativeInputQueue && turnRunning && !(input.delivery === 'steer' && caps.steer)) {
|
|
207
|
+
this.heldInputs.push(input);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
const driver = await this.ensureDriver();
|
|
212
|
+
await driver.send(input);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
const current = this.state.inputs[input.inputId]?.state;
|
|
216
|
+
if (current === 'accepted' || current === 'queued') {
|
|
217
|
+
this.commit([{ type: 'input.state', inputId: input.inputId, state: 'interrupted', reason: 'delivery_failed' }]);
|
|
218
|
+
}
|
|
219
|
+
throw isSessionError(error) ? error : new SessionError('internal', `Could not deliver input: ${error.message}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async ensureDriver() {
|
|
223
|
+
if (this.driver)
|
|
224
|
+
return this.driver;
|
|
225
|
+
if (this.opening)
|
|
226
|
+
return this.opening;
|
|
227
|
+
const generation = this.generation + 1;
|
|
228
|
+
this.liveGeneration = generation;
|
|
229
|
+
this.opening = this.deps.factory.open({
|
|
230
|
+
sessionId: this.sessionId,
|
|
231
|
+
generation,
|
|
232
|
+
cwd: this.config.cwd,
|
|
233
|
+
model: this.config.model,
|
|
234
|
+
effort: this.config.effort,
|
|
235
|
+
systemPrompt: this.config.systemPrompt,
|
|
236
|
+
policy: this.config.policy,
|
|
237
|
+
providerSessionRef: this.state.providerSessionRef,
|
|
238
|
+
}, (event) => this.onDriverEvent(generation, event));
|
|
239
|
+
try {
|
|
240
|
+
const driver = await this.opening;
|
|
241
|
+
this.driver = driver;
|
|
242
|
+
this.generation = generation;
|
|
243
|
+
this.commit([{ type: 'session.process', state: 'started', generation }]);
|
|
244
|
+
this.touch();
|
|
245
|
+
return driver;
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
this.liveGeneration = null;
|
|
249
|
+
throw new SessionError('driver_unavailable', `Could not start ${this.descriptor.id}: ${error.message}`);
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
this.opening = null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// ── Driver events ─────────────────────────────────────────────────────────
|
|
256
|
+
onDriverEvent(generation, event) {
|
|
257
|
+
// Events of a retired process are ignored; a closing process may still report (e.g. stopped tasks).
|
|
258
|
+
if (generation !== this.liveGeneration)
|
|
259
|
+
return;
|
|
260
|
+
if (this.state.status === 'closed')
|
|
261
|
+
return;
|
|
262
|
+
try {
|
|
263
|
+
this.handleDriverEvent(event);
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
if (isSessionError(error) && (error.code === 'illegal_transition' || error.code === 'input_conflict')) {
|
|
267
|
+
this.commit([{ type: 'provider.diagnostic', level: 'warning', code: error.code, message: `${event.kind}: ${error.message}` }]);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
handleDriverEvent(event) {
|
|
274
|
+
this.markActivity();
|
|
275
|
+
switch (event.kind) {
|
|
276
|
+
case 'process.started':
|
|
277
|
+
if (event.version)
|
|
278
|
+
this.commit([{ type: 'provider.diagnostic', level: 'info', code: 'provider_version', message: event.version }]);
|
|
279
|
+
return;
|
|
280
|
+
case 'process.exited':
|
|
281
|
+
if (this.closing)
|
|
282
|
+
return;
|
|
283
|
+
this.driver = null;
|
|
284
|
+
this.liveGeneration = null;
|
|
285
|
+
this.timers.cancel('idle', 'stall', 'backgroundMax', 'turnInactivity', 'settle', 'interruptGrace');
|
|
286
|
+
this.endRunningWork({ turnStatus: 'failed', subagentPhase: 'interrupted', reason: 'crashed', error: `Provider process exited (code ${event.exitCode ?? 'none'}${event.signal ? `, signal ${event.signal}` : ''})` });
|
|
287
|
+
this.commit([{ type: 'session.process', state: 'exited', generation: this.generation, exitCode: event.exitCode, reason: 'crashed' }]);
|
|
288
|
+
this.deps.touched(this);
|
|
289
|
+
return;
|
|
290
|
+
case 'provider.ref':
|
|
291
|
+
if (event.providerSessionRef !== this.state.providerSessionRef) {
|
|
292
|
+
const known = this.deps.journal.baseline(event.providerSessionRef);
|
|
293
|
+
if (known)
|
|
294
|
+
this.baseline = known;
|
|
295
|
+
this.commit([{ type: 'session.provider-ref', providerSessionRef: event.providerSessionRef }]);
|
|
296
|
+
}
|
|
297
|
+
return;
|
|
298
|
+
case 'input.receipt': {
|
|
299
|
+
const input = this.state.inputs[event.inputId];
|
|
300
|
+
if (!input || input.state === event.state)
|
|
301
|
+
return;
|
|
302
|
+
this.commit([{ type: 'input.state', inputId: event.inputId, state: event.state, ...(this.state.openTurn && event.state !== 'queued' ? { turnId: this.state.openTurn.turnId } : {}), ...(event.reason ? { reason: event.reason } : {}) }]);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
case 'turn.started':
|
|
306
|
+
return this.startTurn(event.trigger, event.inputIds);
|
|
307
|
+
case 'turn.output': {
|
|
308
|
+
const turn = this.requireTurn();
|
|
309
|
+
this.commitBuffered(this.output.add({ type: 'turn.output', turnId: turn.turnId, channel: event.channel, delta: event.delta }));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
case 'turn.tool': {
|
|
313
|
+
const turn = this.requireTurn();
|
|
314
|
+
const { kind: _kind, ...tool } = event;
|
|
315
|
+
this.commit([{ type: 'turn.tool', turnId: turn.turnId, ...tool }]);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
case 'turn.completed':
|
|
319
|
+
return this.completeTurn(event.status, event.text, event.usage, event.error);
|
|
320
|
+
case 'subagent.started':
|
|
321
|
+
this.finishedSinceLastTurn.delete(event.subagentId);
|
|
322
|
+
this.commit([{ type: 'subagent.started', subagentId: event.subagentId, parentId: event.parentId, kind: event.agentKind, description: event.description, ...(event.agentType ? { agentType: event.agentType } : {}), ...(event.prompt ? { prompt: event.prompt.slice(0, 8_000) } : {}) }]);
|
|
323
|
+
this.afterSubagentChange();
|
|
324
|
+
if (!nativeSubagentsAllowed(this.config.policy))
|
|
325
|
+
void this.blockSubagent(event.subagentId);
|
|
326
|
+
return;
|
|
327
|
+
case 'subagent.phase':
|
|
328
|
+
// Only work that finished while the agent was not in a turn is news to it (it saw the rest via its own tools).
|
|
329
|
+
if (event.phase !== 'running' && !this.state.openTurn)
|
|
330
|
+
this.finishedSinceLastTurn.add(event.subagentId);
|
|
331
|
+
this.commit([
|
|
332
|
+
{ type: 'subagent.phase', subagentId: event.subagentId, phase: event.phase, ...(event.reason ? { reason: event.reason } : {}) },
|
|
333
|
+
...(event.phase === 'running' ? [] : this.output.drainSubagent(event.subagentId)),
|
|
334
|
+
]);
|
|
335
|
+
this.afterSubagentChange();
|
|
336
|
+
return;
|
|
337
|
+
case 'subagent.output':
|
|
338
|
+
if (event.channel === 'text' && event.delta !== undefined) {
|
|
339
|
+
this.commitBuffered(this.output.add({ type: 'subagent.output', subagentId: event.subagentId, channel: 'text', delta: event.delta }));
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
this.commit([{ type: 'subagent.output', subagentId: event.subagentId, channel: event.channel, ...(event.delta !== undefined ? { delta: event.delta } : {}), ...(event.tool ? { tool: event.tool } : {}) }]);
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
case 'subagent.usage':
|
|
346
|
+
this.commit([{ type: 'subagent.usage', subagentId: event.subagentId, usage: this.subagentUsage(event.usage), ...(event.toolUses !== undefined ? { toolUses: event.toolUses } : {}), ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}) }]);
|
|
347
|
+
return;
|
|
348
|
+
case 'subagent.result':
|
|
349
|
+
this.commit([{ type: 'subagent.result', subagentId: event.subagentId, summary: event.summary.slice(0, 4_096) }]);
|
|
350
|
+
return;
|
|
351
|
+
case 'roster':
|
|
352
|
+
this.roster = [...event.liveSubagentIds];
|
|
353
|
+
this.afterSubagentChange();
|
|
354
|
+
return;
|
|
355
|
+
case 'diagnostic':
|
|
356
|
+
this.commit([{ type: 'provider.diagnostic', level: event.level, code: event.code, message: event.message.slice(0, 2_000) }]);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
startTurn(trigger, inputIds) {
|
|
361
|
+
if (this.state.openTurn)
|
|
362
|
+
throw new SessionError('illegal_transition', `Turn ${this.state.openTurn.turnId} is still running`);
|
|
363
|
+
const system = inputIds.length > 0 && inputIds.every((id) => this.systemInputIds.has(id));
|
|
364
|
+
const origin = trigger === 'continuation' ? 'subagent' : system ? 'system' : 'user';
|
|
365
|
+
const triggeredBy = origin === 'user' ? undefined : [...this.finishedSinceLastTurn];
|
|
366
|
+
if (origin === 'user') {
|
|
367
|
+
this.lastUserTurnAt = this.deps.clock.now();
|
|
368
|
+
// The handoff bound is per settle cycle: a user turn starts a new one.
|
|
369
|
+
this.handoffs = 0;
|
|
370
|
+
}
|
|
371
|
+
this.finishedSinceLastTurn.clear();
|
|
372
|
+
this.timers.cancel('idle', 'stall');
|
|
373
|
+
this.commit([
|
|
374
|
+
...(this.state.phase === 'turn' ? [] : [{ type: 'session.phase', phase: 'turn' }]),
|
|
375
|
+
{ type: 'turn.started', turnId: this.deps.ids.turn(), origin, inputIds: [...inputIds], ...(triggeredBy && triggeredBy.length > 0 ? { trigger: { subagentIds: triggeredBy } } : {}) },
|
|
376
|
+
]);
|
|
377
|
+
this.armTurnInactivity();
|
|
378
|
+
}
|
|
379
|
+
completeTurn(status, text, reported, error) {
|
|
380
|
+
const turn = this.requireTurn();
|
|
381
|
+
this.timers.cancel('turnInactivity', 'interruptGrace');
|
|
382
|
+
const { usage, baselineOption } = this.turnUsage(reported);
|
|
383
|
+
this.commit([
|
|
384
|
+
...this.output.flush(),
|
|
385
|
+
...this.output.drainTurn(turn.turnId),
|
|
386
|
+
{ type: 'turn.completed', turnId: turn.turnId, status, text, usage, ...(error ? { error } : {}) },
|
|
387
|
+
...this.completeTurnInputs(turn.inputIds, turn.turnId),
|
|
388
|
+
], baselineOption);
|
|
389
|
+
this.settlePhaseAfterTurn();
|
|
390
|
+
void this.releaseHeldInput();
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Inputs whose receipts never reported completion are completed with their
|
|
394
|
+
* turn: the ones that opened it and the ones steered into it.
|
|
395
|
+
*/
|
|
396
|
+
completeTurnInputs(inputIds, turnId) {
|
|
397
|
+
return Object.values(this.state.inputs)
|
|
398
|
+
.filter((input) => input.state === 'started' && (inputIds.includes(input.inputId) || input.turnId === turnId))
|
|
399
|
+
.map((input) => ({ type: 'input.state', inputId: input.inputId, state: 'completed', turnId }));
|
|
400
|
+
}
|
|
401
|
+
settlePhaseAfterTurn() {
|
|
402
|
+
const live = liveSubagents(this.state).length;
|
|
403
|
+
this.commit([{ type: 'session.phase', phase: live > 0 ? 'background' : 'idle' }]);
|
|
404
|
+
if (live > 0)
|
|
405
|
+
this.enterBackground();
|
|
406
|
+
else
|
|
407
|
+
this.enterIdle();
|
|
408
|
+
// Sub-agents that finished during the turn still need their settle point.
|
|
409
|
+
if (!this.state.settled.settled && !this.timers.isArmed('settle'))
|
|
410
|
+
this.afterSubagentChange();
|
|
411
|
+
// Delegated results that arrived while the agent was busy are delivered now.
|
|
412
|
+
else if (live === 0)
|
|
413
|
+
this.maybeDeliverDelegatedResults();
|
|
414
|
+
}
|
|
415
|
+
// ── Sub-agent settlement ──────────────────────────────────────────────────
|
|
416
|
+
afterSubagentChange() {
|
|
417
|
+
const live = this.liveCount();
|
|
418
|
+
if (live > 0) {
|
|
419
|
+
this.timers.cancel('settle', 'idle');
|
|
420
|
+
if (this.state.settled.settled || this.state.settled.live !== live)
|
|
421
|
+
this.commit([{ type: 'subagents.settled', settled: false, live }]);
|
|
422
|
+
if (this.state.phase === 'idle' && !this.state.openTurn) {
|
|
423
|
+
// The provider restarted a sub-agent after the session went idle.
|
|
424
|
+
this.commit([{ type: 'session.phase', phase: 'background' }]);
|
|
425
|
+
this.enterBackground();
|
|
426
|
+
}
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (this.state.settled.settled)
|
|
430
|
+
return;
|
|
431
|
+
this.timers.arm('settle', this.config.policy.limits.settleDebounceMs, () => this.onSettled());
|
|
432
|
+
}
|
|
433
|
+
onSettled() {
|
|
434
|
+
if (this.liveCount() > 0 || this.state.status === 'closed')
|
|
435
|
+
return;
|
|
436
|
+
this.commit([{ type: 'subagents.settled', settled: true, live: 0 }]);
|
|
437
|
+
if (this.state.openTurn)
|
|
438
|
+
return;
|
|
439
|
+
if (this.state.phase === 'background') {
|
|
440
|
+
this.commit([{ type: 'session.phase', phase: 'idle' }]);
|
|
441
|
+
this.enterIdle();
|
|
442
|
+
}
|
|
443
|
+
if (!this.maybeDeliverDelegatedResults())
|
|
444
|
+
this.maybeResumeAgent();
|
|
445
|
+
}
|
|
446
|
+
/** Live = running in our tree and, when the provider reports a roster, still on it. */
|
|
447
|
+
liveCount() {
|
|
448
|
+
const running = liveSubagents(this.state).map((node) => node.subagentId);
|
|
449
|
+
if (this.roster === null)
|
|
450
|
+
return running.length;
|
|
451
|
+
return new Set([...running, ...this.roster.filter((id) => this.state.subagents[id]?.phase !== 'idle')]).size;
|
|
452
|
+
}
|
|
453
|
+
maybeResumeAgent() {
|
|
454
|
+
const policy = this.config.policy;
|
|
455
|
+
if (!subagentsActive(policy, this.descriptor) || policy.onSubagentsSettled !== 'resume-agent')
|
|
456
|
+
return;
|
|
457
|
+
if (this.finishedSinceLastTurn.size === 0 || this.handoffs >= policy.limits.maxSettleHandoffs || !this.driver)
|
|
458
|
+
return;
|
|
459
|
+
this.handoffs += 1;
|
|
460
|
+
const inputId = this.deps.ids.input();
|
|
461
|
+
this.systemInputIds.add(inputId);
|
|
462
|
+
this.commit([{ type: 'input.accepted', inputId, delivery: 'queue', text: COLLECT_PROMPT }]);
|
|
463
|
+
void this.deliver({ inputId, text: COLLECT_PROMPT, delivery: 'queue', origin: 'system' }).catch(() => undefined);
|
|
464
|
+
}
|
|
465
|
+
/** Give the agent the results of delegated sub-agents it has not seen. */
|
|
466
|
+
maybeDeliverDelegatedResults() {
|
|
467
|
+
if (this.delegatedResults.size === 0 || this.state.openTurn || this.state.status === 'closed' || this.closing)
|
|
468
|
+
return false;
|
|
469
|
+
if (this.handoffs >= this.config.policy.limits.maxSettleHandoffs)
|
|
470
|
+
return false;
|
|
471
|
+
const results = [...this.delegatedResults.values()];
|
|
472
|
+
this.delegatedResults.clear();
|
|
473
|
+
this.handoffs += 1;
|
|
474
|
+
const inputId = this.deps.ids.input();
|
|
475
|
+
const text = delegatedResultsPrompt(results);
|
|
476
|
+
this.systemInputIds.add(inputId);
|
|
477
|
+
this.commit([{ type: 'input.accepted', inputId, delivery: 'queue', text }]);
|
|
478
|
+
void this.deliver({ inputId, text, delivery: 'queue', origin: 'system' }).catch(() => undefined);
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
// ── Delegated sub-agents (child sessions Core launched for this one) ──────
|
|
482
|
+
/** Running sub-agents Core launched for this session. */
|
|
483
|
+
delegatedLive() {
|
|
484
|
+
return liveSubagents(this.state).filter((node) => node.delegated !== null).map((node) => node.subagentId);
|
|
485
|
+
}
|
|
486
|
+
delegatedStarted(child) {
|
|
487
|
+
this.assertOpen();
|
|
488
|
+
this.commit([{ type: 'subagent.started', subagentId: child.subagentId, parentId: null, kind: 'background', agentType: child.agentType, description: child.description, prompt: child.prompt.slice(0, 8_000), delegated: { driver: child.driver, model: child.model } }]);
|
|
489
|
+
this.afterSubagentChange();
|
|
490
|
+
}
|
|
491
|
+
delegatedOutput(subagentId, event) {
|
|
492
|
+
if (this.state.status === 'closed' || this.state.subagents[subagentId]?.phase !== 'running')
|
|
493
|
+
return;
|
|
494
|
+
if (event.channel === 'text')
|
|
495
|
+
this.commitBuffered(this.output.add({ type: 'subagent.output', subagentId, channel: 'text', delta: event.delta }));
|
|
496
|
+
else
|
|
497
|
+
this.commit([{ type: 'subagent.output', subagentId, channel: 'tool', tool: event.tool }]);
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* A delegated child ended. Its usage is its own provider's spend (billed
|
|
501
|
+
* separately); unless a host already collected the result, the agent gets it
|
|
502
|
+
* when the session next settles.
|
|
503
|
+
*/
|
|
504
|
+
delegatedFinished(subagentId, outcome) {
|
|
505
|
+
const node = this.state.subagents[subagentId];
|
|
506
|
+
if (this.state.status === 'closed' || node?.phase !== 'running')
|
|
507
|
+
return;
|
|
508
|
+
const phase = outcome.status === 'completed' ? 'idle' : outcome.status === 'failed' ? 'failed' : outcome.status === 'interrupted' ? 'interrupted' : 'stopped';
|
|
509
|
+
const result = outcome.text.trim();
|
|
510
|
+
this.commit([
|
|
511
|
+
...this.output.drainSubagent(subagentId),
|
|
512
|
+
...(outcome.usage ? [{ type: 'subagent.usage', subagentId, usage: outcome.usage, billing: 'separate' }] : []),
|
|
513
|
+
...(result ? [{ type: 'subagent.result', subagentId, summary: result.slice(0, 4_096) }] : []),
|
|
514
|
+
{ type: 'subagent.phase', subagentId, phase, ...(outcome.reason ? { reason: outcome.reason } : {}) },
|
|
515
|
+
]);
|
|
516
|
+
if (!outcome.collected && (result || phase !== 'idle')) {
|
|
517
|
+
this.delegatedResults.set(subagentId, { description: node.description, status: phase === 'idle' ? 'completed' : phase, result: result || `It ended without a result (${outcome.reason ?? phase}).` });
|
|
518
|
+
}
|
|
519
|
+
this.afterSubagentChange();
|
|
520
|
+
}
|
|
521
|
+
/** A host received these results through a wait; never inject them again. */
|
|
522
|
+
collectDelegated(subagentIds) {
|
|
523
|
+
for (const id of subagentIds)
|
|
524
|
+
this.delegatedResults.delete(id);
|
|
525
|
+
}
|
|
526
|
+
// ── Phases and limits ─────────────────────────────────────────────────────
|
|
527
|
+
enterIdle() {
|
|
528
|
+
this.timers.cancel('stall', 'backgroundMax');
|
|
529
|
+
if (this.pendingDeferred) {
|
|
530
|
+
const changes = this.pendingDeferred;
|
|
531
|
+
this.pendingDeferred = null;
|
|
532
|
+
void this.applyChanges(changes);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (this.processAlive)
|
|
536
|
+
this.timers.arm('idle', this.config.policy.limits.idleMs, () => void this.retire('idle', this.idleTeardown('idle')));
|
|
537
|
+
this.deps.touched(this);
|
|
538
|
+
}
|
|
539
|
+
enterBackground() {
|
|
540
|
+
const limits = this.config.policy.limits;
|
|
541
|
+
this.timers.arm('stall', limits.stallMs, () => void this.retire('stalled', { turnStatus: 'interrupted', subagentPhase: 'interrupted', reason: 'stalled' }));
|
|
542
|
+
const since = this.lastUserTurnAt ?? this.deps.clock.now();
|
|
543
|
+
const remaining = Math.max(0, limits.backgroundMaxMs - (this.deps.clock.now() - since));
|
|
544
|
+
this.timers.ensure('backgroundMax', remaining, () => void this.retire('limit', { turnStatus: 'stopped', subagentPhase: 'stopped', reason: 'limit' }));
|
|
545
|
+
}
|
|
546
|
+
idleTeardown(reason) {
|
|
547
|
+
return { turnStatus: 'interrupted', subagentPhase: 'interrupted', reason };
|
|
548
|
+
}
|
|
549
|
+
armTurnInactivity() {
|
|
550
|
+
this.timers.arm('turnInactivity', this.config.policy.limits.turnInactivityMs, () => {
|
|
551
|
+
void this.retire('stalled', { turnStatus: 'failed', subagentPhase: 'interrupted', reason: 'stalled', error: 'No provider activity before the inactivity limit' });
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
markActivity() {
|
|
555
|
+
this.lastUsedAt = this.deps.clock.now();
|
|
556
|
+
if (this.state.openTurn)
|
|
557
|
+
this.armTurnInactivity();
|
|
558
|
+
if (this.state.phase === 'background')
|
|
559
|
+
this.timers.arm('stall', this.config.policy.limits.stallMs, () => void this.retire('stalled', { turnStatus: 'interrupted', subagentPhase: 'interrupted', reason: 'stalled' }));
|
|
560
|
+
}
|
|
561
|
+
async applyChanges(changes) {
|
|
562
|
+
const next = changes;
|
|
563
|
+
this.config = {
|
|
564
|
+
...this.config,
|
|
565
|
+
...(next.model !== undefined ? { model: next.model } : {}),
|
|
566
|
+
...(next.effort !== undefined ? { effort: next.effort } : {}),
|
|
567
|
+
...(next.systemPrompt !== undefined ? { systemPrompt: next.systemPrompt } : {}),
|
|
568
|
+
...(next.policy !== undefined ? { policy: next.policy } : {}),
|
|
569
|
+
};
|
|
570
|
+
this.commit([{ type: 'session.updated', changes, outcome: 'applied' }]);
|
|
571
|
+
// Provider flags are fixed at spawn: the next turn starts a new process with the new config.
|
|
572
|
+
if (this.processAlive)
|
|
573
|
+
await this.retire('config_change', this.idleTeardown('config_change'));
|
|
574
|
+
}
|
|
575
|
+
async releaseHeldInput() {
|
|
576
|
+
if (this.state.openTurn)
|
|
577
|
+
return;
|
|
578
|
+
const next = this.heldInputs.shift();
|
|
579
|
+
if (next)
|
|
580
|
+
await this.deliver(next).catch(() => undefined);
|
|
581
|
+
}
|
|
582
|
+
/** End whatever was running; used by retire and unexpected exits. */
|
|
583
|
+
endRunningWork(teardown) {
|
|
584
|
+
// Commit buffered deltas first so the closed turn keeps its partial text.
|
|
585
|
+
if (this.output.hasPending()) {
|
|
586
|
+
this.timers.cancel('flush');
|
|
587
|
+
this.commit(this.output.flush());
|
|
588
|
+
}
|
|
589
|
+
const bodies = [];
|
|
590
|
+
const turn = this.state.openTurn;
|
|
591
|
+
if (turn) {
|
|
592
|
+
bodies.push(...this.output.drainTurn(turn.turnId));
|
|
593
|
+
bodies.push({ type: 'turn.completed', turnId: turn.turnId, status: teardown.turnStatus, text: turn.text, usage: EMPTY_USAGE, ...(teardown.error ? { error: teardown.error } : {}) });
|
|
594
|
+
}
|
|
595
|
+
for (const node of Object.values(this.state.subagents)) {
|
|
596
|
+
// Delegated sub-agents are independent child sessions: they outlive this
|
|
597
|
+
// provider process and end through their own session (or an explicit stop).
|
|
598
|
+
if (node.delegated)
|
|
599
|
+
continue;
|
|
600
|
+
if (node.phase === 'running' || node.phase === 'idle') {
|
|
601
|
+
// Idle sub-agents are finished for the provider; only running ones were cut short.
|
|
602
|
+
if (node.phase === 'running')
|
|
603
|
+
bodies.push({ type: 'subagent.phase', subagentId: node.subagentId, phase: teardown.subagentPhase, reason: teardown.reason });
|
|
604
|
+
bodies.push(...this.output.drainSubagent(node.subagentId));
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
for (const input of Object.values(this.state.inputs)) {
|
|
608
|
+
if (input.state === 'accepted' || input.state === 'queued' || input.state === 'started') {
|
|
609
|
+
bodies.push({ type: 'input.state', inputId: input.inputId, state: 'interrupted', reason: teardown.reason });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
this.heldInputs.length = 0;
|
|
613
|
+
// With delegated work still running the session stays in the background.
|
|
614
|
+
const nextPhase = this.delegatedLive().length > 0 ? 'background' : 'idle';
|
|
615
|
+
if (this.state.phase !== nextPhase || turn)
|
|
616
|
+
bodies.push({ type: 'session.phase', phase: nextPhase });
|
|
617
|
+
const delegatedLive = this.delegatedLive().length;
|
|
618
|
+
if (delegatedLive === 0 && !this.state.settled.settled)
|
|
619
|
+
bodies.push({ type: 'subagents.settled', settled: true, live: 0 });
|
|
620
|
+
this.roster = null;
|
|
621
|
+
if (bodies.length > 0)
|
|
622
|
+
this.commit(bodies);
|
|
623
|
+
}
|
|
624
|
+
// ── Usage ─────────────────────────────────────────────────────────────────
|
|
625
|
+
turnUsage(reported) {
|
|
626
|
+
const result = computeTurnUsage(this.descriptor.capabilities.usage, this.baseline, reported);
|
|
627
|
+
this.baseline = result.baseline;
|
|
628
|
+
const usage = withEstimatedCost(result.usage, this.deps.rateCard(this.descriptor.id, result.usage.model ?? this.config.model));
|
|
629
|
+
const ref = this.state.providerSessionRef;
|
|
630
|
+
return ref ? { usage, baselineOption: { baseline: { providerRef: ref, value: result.baseline } } } : { usage };
|
|
631
|
+
}
|
|
632
|
+
subagentUsage(reported) {
|
|
633
|
+
const usage = {
|
|
634
|
+
inputTokens: reported.inputTokens ?? null,
|
|
635
|
+
outputTokens: reported.outputTokens ?? null,
|
|
636
|
+
cacheReadTokens: reported.cacheReadTokens ?? null,
|
|
637
|
+
cacheWriteTokens: reported.cacheWriteTokens ?? null,
|
|
638
|
+
totalTokens: reported.totalTokens ?? null,
|
|
639
|
+
costUsd: reported.costUsd ?? null,
|
|
640
|
+
costEstimated: false,
|
|
641
|
+
model: reported.model ?? null,
|
|
642
|
+
};
|
|
643
|
+
return withEstimatedCost(usage, this.deps.rateCard(this.descriptor.id, usage.model ?? this.config.model));
|
|
644
|
+
}
|
|
645
|
+
// ── Commit ────────────────────────────────────────────────────────────────
|
|
646
|
+
commitBuffered(ready) {
|
|
647
|
+
if (ready.length > 0)
|
|
648
|
+
this.commit(ready);
|
|
649
|
+
if (this.output.hasPending())
|
|
650
|
+
this.timers.ensure('flush', this.deps.flushMs ?? 50, () => this.commit(this.output.flush()));
|
|
651
|
+
else
|
|
652
|
+
this.timers.cancel('flush');
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Validate (fold) then append atomically, then publish. Buffered deltas are
|
|
656
|
+
* flushed first so ordering is preserved.
|
|
657
|
+
*/
|
|
658
|
+
commit(bodies, options) {
|
|
659
|
+
const isDelta = (body) => body.type === 'turn.output' || (body.type === 'subagent.output' && body.channel === 'text');
|
|
660
|
+
const leading = bodies.some((body) => !isDelta(body)) && this.output.hasPending() ? this.output.flush() : [];
|
|
661
|
+
if (leading.length > 0)
|
|
662
|
+
this.timers.cancel('flush');
|
|
663
|
+
const all = [...leading, ...bodies];
|
|
664
|
+
if (all.length === 0)
|
|
665
|
+
return;
|
|
666
|
+
const at = this.deps.clock.iso();
|
|
667
|
+
const events = all.map((body) => ({ ...body, at }));
|
|
668
|
+
let next = this.state;
|
|
669
|
+
for (const event of events)
|
|
670
|
+
next = applyEvent(next, event);
|
|
671
|
+
const envelopes = this.deps.journal.append(this.sessionId, events, options);
|
|
672
|
+
this.state = { ...next, lastSeq: envelopes.at(-1)?.seq ?? this.state.lastSeq };
|
|
673
|
+
this.deps.publish(envelopes);
|
|
674
|
+
}
|
|
675
|
+
requireTurn() {
|
|
676
|
+
const turn = this.state.openTurn;
|
|
677
|
+
if (!turn)
|
|
678
|
+
throw new SessionError('illegal_transition', 'No turn is running');
|
|
679
|
+
return turn;
|
|
680
|
+
}
|
|
681
|
+
assertOpen() {
|
|
682
|
+
if (this.state.status === 'closed')
|
|
683
|
+
throw new SessionError('session_closed', `Session ${this.sessionId} is closed`);
|
|
684
|
+
}
|
|
685
|
+
touch() {
|
|
686
|
+
this.lastUsedAt = this.deps.clock.now();
|
|
687
|
+
this.deps.touched(this);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
/** Structural equality for configuration values (key order does not matter). */
|
|
691
|
+
function sameValue(left, right) {
|
|
692
|
+
return canonical(left) === canonical(right);
|
|
693
|
+
}
|
|
694
|
+
function canonical(value) {
|
|
695
|
+
if (Array.isArray(value))
|
|
696
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
697
|
+
if (value && typeof value === 'object') {
|
|
698
|
+
return `{${Object.keys(value).sort().filter((key) => value[key] !== undefined).map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
|
|
699
|
+
}
|
|
700
|
+
return JSON.stringify(value) ?? 'undefined';
|
|
701
|
+
}
|