@fastagent-sh/fastagent 0.19.0 → 0.20.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/atomic-write.d.ts +11 -7
- package/dist/atomic-write.js +16 -11
- package/dist/channels/agentcore-limits.d.ts +9 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-service.d.ts +12 -11
- package/dist/channels/agentcore-service.js +14 -18
- package/dist/channels/agentcore.d.ts +11 -19
- package/dist/channels/agentcore.js +14 -7
- package/dist/channels/control.d.ts +50 -12
- package/dist/channels/control.js +239 -96
- package/dist/channels/discover.d.ts +26 -4
- package/dist/channels/discover.js +12 -23
- package/dist/channels/feishu/feishu-api.js +5 -8
- package/dist/channels/feishu/feishu.js +35 -18
- package/dist/channels/kit/attachment-path.d.ts +12 -0
- package/dist/channels/kit/attachment-path.js +43 -0
- package/dist/channels/kit/context-buffer.d.ts +9 -0
- package/dist/channels/kit/context-buffer.js +11 -0
- package/dist/channels/kit/signature.d.ts +12 -0
- package/dist/channels/kit/signature.js +17 -0
- package/dist/channels/kit/stop-command.js +3 -3
- package/dist/channels/kit/tasks.d.ts +1 -1
- package/dist/channels/kit/tasks.js +10 -4
- package/dist/channels/kit/turn-store.d.ts +19 -3
- package/dist/channels/kit/turn-store.js +29 -2
- package/dist/channels/slack/invoke-turn.d.ts +1 -1
- package/dist/channels/slack/invoke-turn.js +5 -1
- package/dist/channels/slack/onboard.js +5 -5
- package/dist/channels/slack/onboarding-state.d.ts +7 -3
- package/dist/channels/slack/onboarding-state.js +13 -20
- package/dist/channels/slack/register-webhook.js +1 -1
- package/dist/channels/slack/scaffold/slack-send.ts +2 -17
- package/dist/channels/slack/slack-api.js +22 -54
- package/dist/channels/slack/slack.js +13 -13
- package/dist/channels/telegram/parse.d.ts +21 -1
- package/dist/channels/telegram/parse.js +65 -11
- package/dist/channels/telegram/scaffold/channel.ts +7 -3
- package/dist/channels/telegram/telegram-api.js +5 -6
- package/dist/channels/telegram/telegram.d.ts +2 -2
- package/dist/channels/telegram/telegram.js +57 -69
- package/dist/cli/add-slack.js +6 -5
- package/dist/cli/commands/add.js +1 -5
- package/dist/cli/commands/attach.d.ts +2 -4
- package/dist/cli/commands/attach.js +17 -15
- package/dist/cli/commands/deploy.d.ts +50 -5
- package/dist/cli/commands/deploy.js +183 -134
- package/dist/cli/commands/dev.js +4 -31
- package/dist/cli/commands/info.js +1 -2
- package/dist/cli/commands/init.js +1 -2
- package/dist/cli/commands/start.js +31 -41
- package/dist/cli/serve.d.ts +12 -21
- package/dist/cli/serve.js +20 -25
- package/dist/cli/shared.d.ts +40 -10
- package/dist/cli/shared.js +51 -4
- package/dist/deploy/agentcore/logs.d.ts +2 -2
- package/dist/deploy/agentcore/logs.js +2 -2
- package/dist/deploy/agentcore/plan.d.ts +12 -10
- package/dist/deploy/agentcore/plan.js +39 -31
- package/dist/deploy/agentcore/run.d.ts +5 -4
- package/dist/deploy/agentcore/run.js +13 -36
- package/dist/deploy/channel-ingress.d.ts +73 -0
- package/dist/deploy/channel-ingress.js +101 -0
- package/dist/deploy/docker/plan.d.ts +3 -7
- package/dist/deploy/docker/plan.js +4 -15
- package/dist/deploy/docker/run.d.ts +18 -1
- package/dist/deploy/docker/run.js +10 -0
- package/dist/deploy/fly/plan.d.ts +4 -5
- package/dist/deploy/fly/plan.js +7 -22
- package/dist/deploy/fly/run.d.ts +9 -23
- package/dist/deploy/fly/run.js +32 -68
- package/dist/deploy/preflight.d.ts +6 -7
- package/dist/deploy/preflight.js +26 -22
- package/dist/deploy/railway/plan.d.ts +4 -5
- package/dist/deploy/railway/plan.js +11 -26
- package/dist/deploy/railway/run.d.ts +9 -9
- package/dist/deploy/railway/run.js +40 -40
- package/dist/deploy/runner.js +22 -2
- package/dist/deploy/secrets.d.ts +18 -9
- package/dist/deploy/secrets.js +64 -12
- package/dist/dev-supervisor.js +3 -4
- package/dist/engines/pi/agent-session-factory.js +20 -39
- package/dist/engines/pi/auth.js +43 -43
- package/dist/engines/pi/config.d.ts +1 -1
- package/dist/engines/pi/create.d.ts +14 -2
- package/dist/engines/pi/create.js +2 -2
- package/dist/engines/pi/definition.d.ts +1 -1
- package/dist/engines/pi/definition.js +3 -4
- package/dist/engines/pi/login.js +32 -16
- package/dist/engines/pi/open.d.ts +1 -2
- package/dist/engines/pi/open.js +1 -1
- package/dist/engines/pi/search-tools.js +1 -1
- package/dist/engines/pi/service.d.ts +1 -1
- package/dist/engines/pi/service.js +8 -0
- package/dist/engines/pi/session-builder.js +6 -33
- package/dist/engines/pi/session-control.d.ts +19 -6
- package/dist/engines/pi/session-control.js +565 -470
- package/dist/engines/pi/session-inheritance.d.ts +8 -22
- package/dist/engines/pi/session-inheritance.js +95 -76
- package/dist/engines/pi/session-markers.d.ts +48 -0
- package/dist/engines/pi/session-markers.js +59 -0
- package/dist/engines/pi/session-settings.d.ts +4 -4
- package/dist/engines/pi/session-settings.js +7 -4
- package/dist/engines/pi/session-store.d.ts +83 -13
- package/dist/engines/pi/session-store.js +408 -55
- package/dist/engines/pi/tool-context.d.ts +21 -8
- package/dist/engines/pi/tool-context.js +29 -4
- package/dist/engines/pi/tool.js +2 -2
- package/dist/env.js +1 -2
- package/dist/loader.d.ts +49 -7
- package/dist/loader.js +79 -18
- package/dist/paths.d.ts +19 -0
- package/dist/paths.js +27 -2
- package/dist/scaffold/add-channel.js +2 -2
- package/dist/scaffold/init.js +7 -2
- package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
- package/dist/schedule/discover.js +3 -15
- package/dist/schedule/wake-alarm.d.ts +13 -7
- package/dist/schedule/wake-alarm.js +88 -46
- package/dist/schedule/wakeups.d.ts +1 -1
- package/dist/schedule/wakeups.js +10 -7
- package/dist/service.d.ts +1 -2
- package/dist/service.js +27 -5
- package/dist/session-remote.d.ts +7 -2
- package/dist/session-remote.js +162 -92
- package/dist/session.d.ts +226 -92
- package/dist/session.js +61 -23
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +1 -1
- package/dist/tunnel.d.ts +18 -3
- package/dist/tunnel.js +32 -40
- package/package.json +1 -1
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The pi implementation of the session control plane
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* {@link SessionObserver} to plug into the invoke pipeline (`createPiAgent({ observer })`)
|
|
6
|
-
* — the hub derives everything from the rich event stream (plus the {@link RunControls} the
|
|
7
|
-
* run_started event carries), holds no durable state of its own, and never writes: durable truth
|
|
8
|
-
* stays in the session repository (read via {@link PiSessionRecordStore}), live truth in the events the
|
|
9
|
-
* data plane emits, modulation in the controls the data plane registers.
|
|
2
|
+
* The pi implementation of the session control plane. `createPiSessionControl` returns the neutral
|
|
3
|
+
* `SessionControl` plus the {@link SessionObserver} to plug into the invoke pipeline
|
|
4
|
+
* (`createPiAgent({ observer })`).
|
|
10
5
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
6
|
+
* It holds no durable state of its own: live truth comes from the event stream (plus the
|
|
7
|
+
* {@link RunControls} a `run_started` carries), durable truth from {@link PiSessionRecordStore} —
|
|
8
|
+
* which is also what performs every write, so how a record takes a property is not knowledge this
|
|
9
|
+
* file has. What it owns is the vocabulary: capability gating, the lease, error codes, and the
|
|
10
|
+
* events its own writes emit.
|
|
11
|
+
*
|
|
12
|
+
* Writes take the same lease as runs. Without boundary wiring they reject before acceptance with
|
|
13
|
+
* `unsupported_capability` — a client gating on `capabilities()` never sends them.
|
|
14
14
|
*/
|
|
15
15
|
import { prepareCompaction } from "@earendil-works/pi-agent-core";
|
|
16
|
+
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
16
17
|
import { SESSION_BUSY_CODE } from "../../agent.js";
|
|
17
|
-
import { BOUNDARY_COMMAND_FAILED_CODE, INVALID_COMMAND_CODE, NO_ACTIVE_RUN_CODE, NOTHING_TO_COMPACT_CODE, NO_SUCH_SESSION_CODE, RUN_COMMAND_FAILED_CODE, UNSUPPORTED_CAPABILITY_CODE, } from "../../session.js";
|
|
18
|
+
import { BOUNDARY_COMMAND_FAILED_CODE, INVALID_COMMAND_CODE, isAddressableSession, NO_ACTIVE_RUN_CODE, NOTHING_TO_COMPACT_CODE, NO_SUCH_SESSION_CODE, PARTIAL_UPDATE_CODE, UPDATE_FIELDS, RUN_COMMAND_FAILED_CODE, UNSUPPORTED_CAPABILITY_CODE, } from "../../session.js";
|
|
18
19
|
import { listModels } from "./config.js";
|
|
20
|
+
import { forkProvenance, isNavigable, publishedLeaf } from "./session-markers.js";
|
|
19
21
|
import { THINKING_LEVELS, activePath, resolveSessionSettings } from "./session-settings.js";
|
|
20
22
|
import { log } from "../../log.js";
|
|
21
23
|
// ── Entry normalization (durable plane) ──────────────────────────────────────
|
|
@@ -37,10 +39,10 @@ function textOf(content) {
|
|
|
37
39
|
* keeps its pi type as an open-set kind with an EMPTY payload — present so `parentId` chains and
|
|
38
40
|
* cursors stay intact, skippable by contract, and no pi message class leaks through the adapter.
|
|
39
41
|
*/
|
|
40
|
-
function toSessionEntry(entry) {
|
|
42
|
+
function toSessionEntry(entry, parentId) {
|
|
41
43
|
const base = {
|
|
42
44
|
id: entry.id,
|
|
43
|
-
parentId
|
|
45
|
+
parentId,
|
|
44
46
|
timestamp: Date.parse(entry.timestamp),
|
|
45
47
|
};
|
|
46
48
|
if (entry.type === "message") {
|
|
@@ -73,20 +75,6 @@ function toSessionEntry(entry) {
|
|
|
73
75
|
}
|
|
74
76
|
return { ...base, kind: entry.type, data: {} };
|
|
75
77
|
}
|
|
76
|
-
/**
|
|
77
|
-
* THE invariant the client's rule rests on: everything `entries()` publishes is a legal `navigate`
|
|
78
|
-
* target. pi's `leaf` records are the exception — they journal a MOVE rather than mark a position
|
|
79
|
-
* (their parentId is the OLD leaf, nothing is ever chained onto them), so navigating to one would
|
|
80
|
-
* put the branch head off every conversation path. Withheld from the published plane and refused as
|
|
81
|
-
* a target THROUGH THIS ONE PREDICATE, so a second exclusion cannot make the two disagree.
|
|
82
|
-
*/
|
|
83
|
-
/** Every position a client may move the branch head to. `label` is metadata ABOUT an entry, not a
|
|
84
|
-
* position in the conversation, so it is neither published nor navigable — the client's rule stays
|
|
85
|
-
* "anything published is navigable". pi's own branch() is a pointer move and writes no record, so
|
|
86
|
-
* there is nothing else to exclude. */
|
|
87
|
-
function isNavigable(entry) {
|
|
88
|
-
return entry.type !== "label";
|
|
89
|
-
}
|
|
90
78
|
// ── Live fan-out (events plane) ──────────────────────────────────────────────
|
|
91
79
|
/** Ceiling for one subscriber's unconsumed backlog. A consumer this far behind (a stalled remote
|
|
92
80
|
* connection — the wire's ReadableStream backpressure stops pulling while invokes keep pushing)
|
|
@@ -201,7 +189,8 @@ export function createPiSessionControl(options) {
|
|
|
201
189
|
}
|
|
202
190
|
fanOut(session, event);
|
|
203
191
|
};
|
|
204
|
-
|
|
192
|
+
// The reads, plus the two sessionless declarations. Bound onto a handle below.
|
|
193
|
+
const reads = {
|
|
205
194
|
async commands() {
|
|
206
195
|
return (await options.commands?.()) ?? [];
|
|
207
196
|
},
|
|
@@ -210,14 +199,19 @@ export function createPiSessionControl(options) {
|
|
|
210
199
|
return {
|
|
211
200
|
steering: true,
|
|
212
201
|
followUp: true,
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
//
|
|
202
|
+
compaction: !!b,
|
|
203
|
+
// Every write — a property, a copied record, a removed one — needs the boundary wiring for
|
|
204
|
+
// its LEASE, so they answer the same question: a write that races a run would hang the next
|
|
205
|
+
// turn off a stale branch, or pull the record out from under it.
|
|
206
|
+
fork: !!b,
|
|
207
|
+
delete: !!b,
|
|
208
|
+
// The CONTRACT's list, not a copy of it: a field added to SessionUpdate is advertised
|
|
209
|
+
// without anyone remembering to, and one removed cannot linger here.
|
|
210
|
+
updatable: b ? [...UPDATE_FIELDS] : [],
|
|
211
|
+
// The registry is a deployment fact (any session may be pointed at any of it). Thinking
|
|
212
|
+
// LEVELS are a property of the model a session is running, so they ride
|
|
216
213
|
// `state().availableThinkingLevels` — a list here could only answer for one model.
|
|
217
|
-
|
|
218
|
-
// Gated on the boundary wiring for its LEASE, not its models: moving the leaf is a write,
|
|
219
|
-
// and a write that races a run would hang the next turn off a stale branch.
|
|
220
|
-
navigate: !!b,
|
|
214
|
+
...(b ? { allowedModels: listModels(b.models) } : {}),
|
|
221
215
|
toolProgress: true, // tool_progress IS delivered (replace-semantics snapshots)
|
|
222
216
|
usage: false,
|
|
223
217
|
};
|
|
@@ -225,7 +219,7 @@ export function createPiSessionControl(options) {
|
|
|
225
219
|
async state(session) {
|
|
226
220
|
const run = active.get(session);
|
|
227
221
|
const opened = await sessions.openIfExists(session);
|
|
228
|
-
const leafEntryId = opened ? (opened
|
|
222
|
+
const leafEntryId = opened ? publishedLeaf(opened) : undefined;
|
|
229
223
|
// What will RUN, not the raw record: a client steering a session needs the pair that executes.
|
|
230
224
|
// Without a boundary there is no model to resolve against, and the fields are absent.
|
|
231
225
|
// OBSERVATION IS TOTAL: an unreadable entry chain leaves the pair absent too (the same shape a
|
|
@@ -243,8 +237,10 @@ export function createPiSessionControl(options) {
|
|
|
243
237
|
log.warn(`[fastagent] session ${session}: settings unreadable (entry chain): ${String(error)}`);
|
|
244
238
|
}
|
|
245
239
|
}
|
|
240
|
+
const name = opened?.getSessionName();
|
|
246
241
|
return {
|
|
247
242
|
status: run ? "running" : compacting.has(session) ? "compacting" : "idle",
|
|
243
|
+
...(name ? { name } : {}),
|
|
248
244
|
...(run ? { activeRunId: run.runId } : {}),
|
|
249
245
|
...(settings
|
|
250
246
|
? {
|
|
@@ -265,8 +261,28 @@ export function createPiSessionControl(options) {
|
|
|
265
261
|
// would race any concurrent append into a leaf the snapshot cannot contain — a live turn
|
|
266
262
|
// reading as a dangling head. This order makes the journal a superset of the leaf's chain,
|
|
267
263
|
// which is what lets the published head be trusted as one of the published entries.
|
|
268
|
-
const leafEntryId = opened
|
|
269
|
-
const
|
|
264
|
+
const leafEntryId = publishedLeaf(opened);
|
|
265
|
+
const journal = opened.getEntries();
|
|
266
|
+
// The published tree must be SELF-CONTAINED: a `parentId` pointing at an entry this plane does
|
|
267
|
+
// not publish (a label, one of our markers) would break the walk a client does from
|
|
268
|
+
// `leafEntryId` upward — it would stop at an id it cannot look up and report a short path.
|
|
269
|
+
// So a skipped entry is spliced out: its children point at the nearest published ancestor.
|
|
270
|
+
const byId = new Map(journal.map((e) => [e.id, e]));
|
|
271
|
+
const publishedParent = (entry) => {
|
|
272
|
+
let parent = entry.parentId ?? undefined;
|
|
273
|
+
while (parent) {
|
|
274
|
+
const found = byId.get(parent);
|
|
275
|
+
// A gap in the chain is left as a gap — `state()` reports it and the next invoke fails on
|
|
276
|
+
// it (design §7); inventing a parent here would hide a corrupt journal.
|
|
277
|
+
if (!found)
|
|
278
|
+
return parent;
|
|
279
|
+
if (isNavigable(found))
|
|
280
|
+
return parent;
|
|
281
|
+
parent = found.parentId ?? undefined;
|
|
282
|
+
}
|
|
283
|
+
return undefined;
|
|
284
|
+
};
|
|
285
|
+
const all = journal.filter(isNavigable).map((e) => toSessionEntry(e, publishedParent(e)));
|
|
270
286
|
let entries = all;
|
|
271
287
|
if (opts?.since !== undefined) {
|
|
272
288
|
const idx = all.findIndex((e) => e.id === opts.since);
|
|
@@ -333,440 +349,519 @@ export function createPiSessionControl(options) {
|
|
|
333
349
|
},
|
|
334
350
|
};
|
|
335
351
|
},
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
352
|
+
};
|
|
353
|
+
/** No boundary wiring — the write path does not exist in this deployment. A capability-gating
|
|
354
|
+
* client never lands here; one that does gets the same answer every gate publishes. */
|
|
355
|
+
const unsupported = (what) => ({
|
|
356
|
+
ok: false,
|
|
357
|
+
error: {
|
|
358
|
+
code: UNSUPPORTED_CAPABILITY_CODE,
|
|
359
|
+
message: `${what} is not supported by this runtime (no boundary wiring)`,
|
|
360
|
+
retryable: false,
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
const noSuchSession = (session) => ({
|
|
364
|
+
ok: false,
|
|
365
|
+
error: { code: NO_SUCH_SESSION_CODE, message: `session "${session}" does not exist`, retryable: false },
|
|
366
|
+
});
|
|
367
|
+
const invalid = (message) => ({
|
|
368
|
+
ok: false,
|
|
369
|
+
error: { code: INVALID_COMMAND_CODE, message, retryable: false },
|
|
370
|
+
});
|
|
371
|
+
const busy = () => ({
|
|
372
|
+
ok: false,
|
|
373
|
+
error: {
|
|
374
|
+
code: SESSION_BUSY_CODE,
|
|
375
|
+
message: "session busy: a run (or another write) is in flight — retry at idle",
|
|
376
|
+
retryable: true,
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
const failed = (error) => ({
|
|
380
|
+
ok: false,
|
|
381
|
+
error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
|
|
382
|
+
});
|
|
383
|
+
/** steer / follow_up / abort — the run actions. They reach the LIVE run through the controls
|
|
384
|
+
* registered with `run_started`; nothing durable is written. */
|
|
385
|
+
const runAction = async (session, action) => {
|
|
386
|
+
const run = active.get(session);
|
|
387
|
+
if (!run) {
|
|
388
|
+
// Run/compaction symmetry: an in-flight compaction is a model call too, and `abort` is its
|
|
389
|
+
// only door — interrupting it converges through the detached task's catch into
|
|
390
|
+
// `compaction_finished{aborted}` with the lease released; answering no_active_run against a
|
|
391
|
+
// state() that says "compacting" would be a lie.
|
|
392
|
+
const comp = action.type === "abort" ? compacting.get(session) : undefined;
|
|
393
|
+
if (comp) {
|
|
394
|
+
comp.abort();
|
|
395
|
+
return { ok: true }; // no runId — the outcome travels as compaction_finished{aborted}
|
|
396
|
+
}
|
|
397
|
+
// Rejected BEFORE acceptance: no run exists, nothing happened. retryable: false — the same
|
|
398
|
+
// call fails again; call it after state() shows an active run.
|
|
399
|
+
return {
|
|
400
|
+
ok: false,
|
|
401
|
+
error: {
|
|
402
|
+
code: NO_ACTIVE_RUN_CODE,
|
|
403
|
+
message: `no active run for this session — ${action.type} modulates a run an invoke is driving`,
|
|
404
|
+
retryable: false,
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
if (!run.controls) {
|
|
409
|
+
// A run EXISTS (state() rightly reports running) but was registered observation-only (the
|
|
410
|
+
// observer seam allows run_started without controls). That is a CAPABILITY problem, not a run
|
|
411
|
+
// problem — permanent for this wiring, so neither no_active_run (would poll forever) nor
|
|
412
|
+
// run_command_failed (transient) fits.
|
|
413
|
+
return {
|
|
414
|
+
ok: false,
|
|
415
|
+
error: {
|
|
416
|
+
code: UNSUPPORTED_CAPABILITY_CODE,
|
|
417
|
+
message: `the active run registered without modulation controls (observation-only) — ${action.type} cannot reach it`,
|
|
418
|
+
retryable: false,
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
if (action.type === "steer")
|
|
424
|
+
await run.controls.steer(action.prompt);
|
|
425
|
+
else if (action.type === "follow_up")
|
|
426
|
+
await run.controls.followUp(action.prompt);
|
|
427
|
+
else
|
|
428
|
+
await run.controls.abort();
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
// The run raced us to settlement, failed setup, or the engine refused: still pre-acceptance
|
|
432
|
+
// (nothing was queued), distinct from "no run existed". retryable: false for the same reason —
|
|
433
|
+
// the run is gone; consult state() before calling again.
|
|
434
|
+
return { ok: false, error: { code: RUN_COMMAND_FAILED_CODE, message: String(error), retryable: false } };
|
|
435
|
+
}
|
|
436
|
+
// Accepted: joined (or stopped) THIS run. The outcome arrives as run_settled.
|
|
437
|
+
return { ok: true, runId: run.runId };
|
|
438
|
+
};
|
|
439
|
+
/**
|
|
440
|
+
* {@link Session.update} — validate the whole patch, take the lease once, hand the writes to the
|
|
441
|
+
* store, report what landed.
|
|
442
|
+
*
|
|
443
|
+
* Everything before the lease is validation, which is what makes a rejected patch leave nothing
|
|
444
|
+
* behind. What a value MEANS is decided here (a model spec against the registry, a level against
|
|
445
|
+
* the model it lands on); how a record takes it is the store's.
|
|
446
|
+
*/
|
|
447
|
+
const updateOf = async (session, patch) => {
|
|
448
|
+
// Keys, not values: a field this runtime does not know must not be silently skipped — that is a
|
|
449
|
+
// client typo, or a newer client talking to an older serve, and both need to hear about it.
|
|
450
|
+
const named = Object.keys(patch);
|
|
451
|
+
const unknown = named.filter((f) => !UPDATE_FIELDS.includes(f));
|
|
452
|
+
if (unknown.length > 0) {
|
|
453
|
+
return unsupported(`update field(s) ${unknown.join(", ")} — capabilities().updatable lists what this serve sets`);
|
|
454
|
+
}
|
|
455
|
+
const fields = named.filter((f) => patch[f] !== undefined);
|
|
456
|
+
if (fields.length === 0)
|
|
457
|
+
return { ok: true }; // an empty patch asks for nothing, and gets it
|
|
458
|
+
const b = boundary?.();
|
|
459
|
+
if (!b)
|
|
460
|
+
return unsupported(`update(${fields.join(", ")})`);
|
|
461
|
+
// PAYLOAD validation first — before the session is even opened, and long before the lease: an
|
|
462
|
+
// invalid value must not briefly block a run.
|
|
463
|
+
let model;
|
|
464
|
+
if (patch.model !== undefined) {
|
|
465
|
+
const slash = patch.model.indexOf("/");
|
|
466
|
+
model = slash > 0 ? b.models.getModel(patch.model.slice(0, slash), patch.model.slice(slash + 1)) : undefined;
|
|
467
|
+
if (!model) {
|
|
468
|
+
return invalid(`unknown model "${patch.model}" — capabilities().allowedModels lists the accepted specs`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (patch.thinkingLevel !== undefined && !THINKING_LEVELS.has(patch.thinkingLevel)) {
|
|
472
|
+
return invalid(`unknown thinking level "${patch.thinkingLevel}" — state().availableThinkingLevels lists what this session accepts`);
|
|
473
|
+
}
|
|
474
|
+
// A name is the client's own label; the only thing that cannot be one is nothing.
|
|
475
|
+
if (patch.name !== undefined && patch.name.trim() === "")
|
|
476
|
+
return invalid("a session name cannot be empty");
|
|
477
|
+
// Sessions are created by invoke or copied by fork, never minted by an update: an unknown id is
|
|
478
|
+
// rejected, not turned into a ghost record. (Read-only handle — the WRITE one is opened under
|
|
479
|
+
// the lease below, and this one is discarded.)
|
|
480
|
+
const existing = await sessions.openIfExists(session);
|
|
481
|
+
if (!existing)
|
|
482
|
+
return noSuchSession(session);
|
|
483
|
+
if (patch.leafEntryId !== undefined) {
|
|
484
|
+
// A target that cannot BE a leaf is a permanent payload error, not a session error — the same
|
|
485
|
+
// disposition as an unknown model spec. Same predicate `entries()` publishes by, so
|
|
486
|
+
// "everything published is a position" holds by construction rather than by two literals
|
|
487
|
+
// agreeing.
|
|
488
|
+
const entry = existing.getEntry(patch.leafEntryId);
|
|
489
|
+
if (!entry || !isNavigable(entry)) {
|
|
490
|
+
return invalid(entry
|
|
491
|
+
? `entry "${patch.leafEntryId}" is not a position — entries() publishes every id you can move to, and this is not one of them`
|
|
492
|
+
: `entry "${patch.leafEntryId}" does not exist in session "${session}" — entries() lists the positions`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (patch.thinkingLevel !== undefined) {
|
|
496
|
+
// The same set `state()` showed the client. Reject here rather than record a level the run
|
|
497
|
+
// would not use. The read is guarded because this must never REJECT — the contract promises a
|
|
498
|
+
// SessionResult, so an unreadable chain has to arrive as a code.
|
|
499
|
+
let resolved;
|
|
500
|
+
try {
|
|
501
|
+
// Against the path this patch LANDS on: a leaf move is written first, and the branch it
|
|
502
|
+
// moves to can carry a model override of its own — validating on the path being left would
|
|
503
|
+
// reject a level the destination supports, and accept one it does not.
|
|
504
|
+
resolved = resolveSessionSettings(activePath(existing, patch.leafEntryId), b.models, b.defaults);
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
return failed(error);
|
|
508
|
+
}
|
|
509
|
+
// An explicit model in the same patch wins over the one that path resolves to: it is applied
|
|
510
|
+
// after the move, so it is what the session ends up running.
|
|
511
|
+
const target = model ?? resolved.model;
|
|
512
|
+
const levels = model ? getSupportedThinkingLevels(model) : resolved.availableThinkingLevels;
|
|
513
|
+
if (!levels.includes(patch.thinkingLevel)) {
|
|
514
|
+
return invalid(`thinking level "${patch.thinkingLevel}" is not supported by ${target.provider}/${target.id} (allowed: ${levels.join(", ")})`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
// The control plane's writes take the same lease as every run — a write must never race one
|
|
518
|
+
// (design §9).
|
|
519
|
+
const release = b.lease.tryAcquire(session);
|
|
520
|
+
if (!release)
|
|
521
|
+
return busy();
|
|
522
|
+
let applied;
|
|
523
|
+
try {
|
|
524
|
+
// HOW a record takes a property — order, the leaf pointer, the name pi rewrites — is the
|
|
525
|
+
// store's to know. This asks for the writes and is told what landed.
|
|
526
|
+
applied = await sessions.applyProperties(session, {
|
|
527
|
+
...(patch.name !== undefined ? { name: patch.name } : {}),
|
|
528
|
+
...(model ? { model: { provider: model.provider, id: model.id } } : {}),
|
|
529
|
+
...(patch.thinkingLevel !== undefined ? { thinkingLevel: patch.thinkingLevel } : {}),
|
|
530
|
+
...(patch.leafEntryId !== undefined ? { leafEntryId: patch.leafEntryId } : {}),
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
// Opening the record failed — nothing was written; the same patch may succeed on retry. The
|
|
535
|
+
// lease is freed by the `finally` on the way out, once.
|
|
536
|
+
return failed(error);
|
|
537
|
+
}
|
|
538
|
+
finally {
|
|
539
|
+
release();
|
|
540
|
+
}
|
|
541
|
+
if (!applied)
|
|
542
|
+
return noSuchSession(session); // vanished in the window: same condition, same code
|
|
543
|
+
if (applied.landed.length > 0) {
|
|
544
|
+
// ONE event for the patch, built from what the RECORD holds. The settings pair rides along
|
|
545
|
+
// whenever anything but the name changed: model and thinking level are one setting, and a
|
|
546
|
+
// moved leaf can drop an override that used to apply.
|
|
547
|
+
let settings;
|
|
548
|
+
if (applied.path) {
|
|
549
|
+
try {
|
|
550
|
+
settings = resolveSessionSettings(applied.path, b.models, b.defaults);
|
|
396
551
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
if (!b) {
|
|
403
|
-
// No boundary wiring: rejected before acceptance; a capability-gating client never
|
|
404
|
-
// lands here.
|
|
405
|
-
return {
|
|
406
|
-
ok: false,
|
|
407
|
-
error: {
|
|
408
|
-
code: UNSUPPORTED_CAPABILITY_CODE,
|
|
409
|
-
message: `command "${command.type}" is not supported by this runtime (no boundary wiring)`,
|
|
410
|
-
retryable: false,
|
|
411
|
-
},
|
|
412
|
-
};
|
|
413
|
-
}
|
|
414
|
-
// Payload validation BEFORE the lease — an invalid value must not briefly block a run.
|
|
415
|
-
/** The durable write for set_model/set_thinking/navigate — undefined for compact, which
|
|
416
|
-
* writes through pi's own compaction. Answers the event to emit. */
|
|
417
|
-
let apply;
|
|
418
|
-
if (command.type === "set_model") {
|
|
419
|
-
const slash = command.model.indexOf("/");
|
|
420
|
-
const model = slash > 0 ? b.models.getModel(command.model.slice(0, slash), command.model.slice(slash + 1)) : undefined;
|
|
421
|
-
if (!model) {
|
|
422
|
-
return {
|
|
423
|
-
ok: false,
|
|
424
|
-
error: {
|
|
425
|
-
code: INVALID_COMMAND_CODE,
|
|
426
|
-
message: `unknown model "${command.model}" — capabilities().modelSelection lists the allowed specs`,
|
|
427
|
-
retryable: false,
|
|
428
|
-
},
|
|
429
|
-
};
|
|
430
|
-
}
|
|
431
|
-
apply = async (s) => {
|
|
432
|
-
s.appendModelChange(model.provider, model.id);
|
|
433
|
-
// Both halves: a new model can change which level executes. Nothing is re-recorded to
|
|
434
|
-
// make that true — the resolve reports it, so the preference survives a round trip.
|
|
435
|
-
const settings = resolveSessionSettings(activePath(s), b.models, b.defaults);
|
|
436
|
-
return {
|
|
437
|
-
type: "state_changed",
|
|
438
|
-
timestamp: Date.now(),
|
|
439
|
-
// The CANONICAL spec, same string the durable entry and state() report — the event
|
|
440
|
-
// must not echo a client alias the other two surfaces would disagree with.
|
|
441
|
-
data: { model: `${model.provider}/${model.id}`, thinkingLevel: settings.thinkingLevel },
|
|
442
|
-
};
|
|
443
|
-
};
|
|
444
|
-
}
|
|
445
|
-
else if (command.type === "set_thinking") {
|
|
446
|
-
// A payload that is not a level at all — invalid before any session question.
|
|
447
|
-
if (!THINKING_LEVELS.has(command.level)) {
|
|
448
|
-
return {
|
|
449
|
-
ok: false,
|
|
450
|
-
error: {
|
|
451
|
-
code: INVALID_COMMAND_CODE,
|
|
452
|
-
message: `unknown thinking level "${command.level}" — state().availableThinkingLevels lists what this session accepts`,
|
|
453
|
-
retryable: false,
|
|
454
|
-
},
|
|
455
|
-
};
|
|
456
|
-
}
|
|
457
|
-
apply = async (s) => {
|
|
458
|
-
s.appendThinkingLevelChange(command.level);
|
|
459
|
-
return { type: "state_changed", timestamp: Date.now(), data: { thinkingLevel: command.level } };
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
|
-
else if (command.type === "navigate") {
|
|
463
|
-
apply = async (s) => {
|
|
464
|
-
// A move to where the leaf already is writes nothing: pi journals a move as a `leaf`
|
|
465
|
-
// record, so an idempotent re-dispatch (a client retry, a UI firing on every
|
|
466
|
-
// selection) would otherwise grow the session by a record no plane publishes. The
|
|
467
|
-
// EVENT is emitted either way — it reports the resulting position, not the fact that
|
|
468
|
-
// a record was written, and a client that dispatched must not have to poll for it.
|
|
469
|
-
if (s.getLeafId() !== command.targetId)
|
|
470
|
-
s.branch(command.targetId);
|
|
471
|
-
// branch()'s postcondition IS "targetId is the leaf" (it validates, then sets); a
|
|
472
|
-
// failure throws and travels as boundary_command_failed, so a read-back could only
|
|
473
|
-
// re-report what this line already knows. The SETTINGS ride along because a move can
|
|
474
|
-
// change them — an override recorded on the branch just left stops applying, and a
|
|
475
|
-
// client tracking model/level from the event stream would otherwise show what the
|
|
476
|
-
// next turn will not use.
|
|
477
|
-
// The move is already durable here, so a settings read that throws (the new path is
|
|
478
|
-
// above a gap) must NOT turn into "nothing took effect": report the position without
|
|
479
|
-
// the settings and let `state()`'s rejection be where the broken chain surfaces.
|
|
480
|
-
let settings;
|
|
481
|
-
try {
|
|
482
|
-
settings = resolveSessionSettings(activePath(s), b.models, b.defaults);
|
|
483
|
-
}
|
|
484
|
-
catch (error) {
|
|
485
|
-
// Absent rather than stale: the session cannot RUN with an unreadable chain either
|
|
486
|
-
// (binding a session walks the same path), so the next invoke fails visibly — this
|
|
487
|
-
// event does not need to carry a second signal for it.
|
|
488
|
-
log.warn(`[fastagent] session ${session}: leaf moved, settings unreadable: ${String(error)}`);
|
|
489
|
-
}
|
|
490
|
-
return {
|
|
491
|
-
type: "state_changed",
|
|
492
|
-
timestamp: Date.now(),
|
|
493
|
-
data: {
|
|
494
|
-
leafEntryId: command.targetId,
|
|
495
|
-
...(settings
|
|
496
|
-
? {
|
|
497
|
-
model: `${settings.model.provider}/${settings.model.id}`,
|
|
498
|
-
thinkingLevel: settings.thinkingLevel,
|
|
499
|
-
}
|
|
500
|
-
: {}),
|
|
501
|
-
},
|
|
502
|
-
};
|
|
503
|
-
};
|
|
504
|
-
}
|
|
505
|
-
// Sessions are created by invoke, never here: a mutation on an unknown id is rejected,
|
|
506
|
-
// not minted into a ghost record. (Existence check before the lease — read-only; the
|
|
507
|
-
// WRITE handle is re-opened under the lease below, this one is discarded.)
|
|
508
|
-
const existing = await sessions.openIfExists(session);
|
|
509
|
-
if (!existing) {
|
|
510
|
-
return {
|
|
511
|
-
ok: false,
|
|
512
|
-
error: {
|
|
513
|
-
code: NO_SUCH_SESSION_CODE,
|
|
514
|
-
message: `session "${session}" does not exist — sessions are created by invoke, not by boundary mutations`,
|
|
515
|
-
retryable: false,
|
|
516
|
-
},
|
|
517
|
-
};
|
|
518
|
-
}
|
|
519
|
-
if (command.type === "navigate") {
|
|
520
|
-
// A target that cannot BE a leaf is a permanent payload error, not a session error — the
|
|
521
|
-
// same disposition as an unknown model spec. Same predicate `entries()` publishes by, so
|
|
522
|
-
// "everything published is navigable" holds by construction rather than by two literals
|
|
523
|
-
// agreeing.
|
|
524
|
-
const entry = existing.getEntry(command.targetId);
|
|
525
|
-
if (!entry || !isNavigable(entry)) {
|
|
526
|
-
return {
|
|
527
|
-
ok: false,
|
|
528
|
-
error: {
|
|
529
|
-
code: INVALID_COMMAND_CODE,
|
|
530
|
-
message: entry
|
|
531
|
-
? `entry "${command.targetId}" is a leaf-move record — entries() does not publish those, and they are not positions; navigate to the entry it points at`
|
|
532
|
-
: `entry "${command.targetId}" does not exist in session "${session}" — entries() lists the navigable ids`,
|
|
533
|
-
retryable: false,
|
|
534
|
-
},
|
|
535
|
-
};
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
if (command.type === "set_thinking") {
|
|
539
|
-
// The same set `state()` showed the client. Reject here rather than record a level the
|
|
540
|
-
// run would not use. The read is guarded because `dispatch` must never REJECT — the
|
|
541
|
-
// transport promises a SessionResult, so an unreadable chain has to arrive as a code.
|
|
542
|
-
let resolved;
|
|
543
|
-
try {
|
|
544
|
-
resolved = resolveSessionSettings(activePath(existing), b.models, b.defaults);
|
|
545
|
-
}
|
|
546
|
-
catch (error) {
|
|
547
|
-
return {
|
|
548
|
-
ok: false,
|
|
549
|
-
error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
|
|
550
|
-
};
|
|
551
|
-
}
|
|
552
|
-
const { model, availableThinkingLevels } = resolved;
|
|
553
|
-
if (!availableThinkingLevels.includes(command.level)) {
|
|
554
|
-
return {
|
|
555
|
-
ok: false,
|
|
556
|
-
error: {
|
|
557
|
-
code: INVALID_COMMAND_CODE,
|
|
558
|
-
message: `thinking level "${command.level}" is not supported by ${model.provider}/${model.id} (allowed: ${availableThinkingLevels.join(", ")})`,
|
|
559
|
-
retryable: false,
|
|
560
|
-
},
|
|
561
|
-
};
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
// Boundary mutations are the control plane's only writers: same lease as every run — a
|
|
565
|
-
// mutation must never race one (design §9).
|
|
566
|
-
const release = b.lease.tryAcquire(session);
|
|
567
|
-
if (!release) {
|
|
568
|
-
return {
|
|
569
|
-
ok: false,
|
|
570
|
-
error: {
|
|
571
|
-
code: SESSION_BUSY_CODE,
|
|
572
|
-
message: "session busy: a run (or another boundary mutation) is in flight — retry at idle",
|
|
573
|
-
retryable: true,
|
|
574
|
-
},
|
|
575
|
-
};
|
|
576
|
-
}
|
|
577
|
-
if (command.type === "compact") {
|
|
578
|
-
// ACCEPT-FAST: compaction is a full model call (tens of seconds is normal) — holding
|
|
579
|
-
// the dispatch open until it finishes made acceptance = outcome, the one exception to
|
|
580
|
-
// §5.2, and broke remote clients whose request timeouts are sized for control calls.
|
|
581
|
-
// The dispatch answers once the work is ADMITTED (lease held, session bound); the
|
|
582
|
-
// outcome travels as compaction_finished{summary|error|aborted}, the bounds contract
|
|
583
|
-
// watchers already rely on.
|
|
584
|
-
//
|
|
585
|
-
// The admission step is everything cheap and local: binding the session (the ONE
|
|
586
|
-
// canonical resolution of overrides + auth) plus the compaction PREPARATION, a pure
|
|
587
|
-
// branch read. The boundary between "reject the dispatch" and "the outcome travels as
|
|
588
|
-
// an event" sits where the work becomes asynchronous and expensive: the model call.
|
|
589
|
-
// "Nothing to compact" is therefore a pre-acceptance answer, never a finished{error}
|
|
590
|
-
// dressed as a failure — pi reports it as a throw from compact(), too late to reject.
|
|
591
|
-
let bound;
|
|
592
|
-
try {
|
|
593
|
-
bound = await b.sessionFactory(session);
|
|
594
|
-
}
|
|
595
|
-
catch (error) {
|
|
596
|
-
release();
|
|
597
|
-
return {
|
|
598
|
-
ok: false,
|
|
599
|
-
error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
|
|
600
|
-
};
|
|
601
|
-
}
|
|
602
|
-
const teardown = () => {
|
|
603
|
-
try {
|
|
604
|
-
bound.dispose();
|
|
605
|
-
}
|
|
606
|
-
catch (error) {
|
|
607
|
-
log.warn(`[fastagent] compaction session teardown failed: ${String(error)}`);
|
|
608
|
-
}
|
|
609
|
-
};
|
|
610
|
-
try {
|
|
611
|
-
// The SAME settings pi will use inside compact(): asking with different thresholds
|
|
612
|
-
// would either reject a compaction pi would have run, or admit one it refuses - and
|
|
613
|
-
// its refusal arrives too late to be a pre-acceptance answer.
|
|
614
|
-
const path = bound.sessionManager.getBranch();
|
|
615
|
-
const prep = prepareCompaction(path, bound.settingsManager.getCompactionSettings());
|
|
616
|
-
if (!prep.ok)
|
|
617
|
-
throw prep.error;
|
|
618
|
-
// Empty is the same answer as absent: pi ships two prepareCompaction implementations
|
|
619
|
-
// (agent-core answers with a Result, coding-agent with undefined) and they disagree on
|
|
620
|
-
// which one an unsummarizable session gets. What they agree on is the CONTENT — no
|
|
621
|
-
// messages to summarize — so that is what the gate reads.
|
|
622
|
-
if (!prep.value || prep.value.messagesToSummarize.length === 0) {
|
|
623
|
-
teardown();
|
|
624
|
-
release();
|
|
625
|
-
// A no-op, not a failure — its OWN code (the NO_ACTIVE_RUN pattern): a client must
|
|
626
|
-
// machine-distinguish "give up" from "re-dispatch once the session grows", and
|
|
627
|
-
// branching on message prose is forbidden by contract.
|
|
628
|
-
return {
|
|
629
|
-
ok: false,
|
|
630
|
-
error: {
|
|
631
|
-
code: NOTHING_TO_COMPACT_CODE,
|
|
632
|
-
message: "nothing to compact — the session has no compactable history yet; retry after more turns",
|
|
633
|
-
retryable: false,
|
|
634
|
-
},
|
|
635
|
-
};
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
catch (error) {
|
|
639
|
-
teardown();
|
|
640
|
-
release();
|
|
641
|
-
return {
|
|
642
|
-
ok: false,
|
|
643
|
-
error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
|
|
644
|
-
};
|
|
645
|
-
}
|
|
646
|
-
// The door is the session's own compaction abort — a real one, unlike a summarization
|
|
647
|
-
// call with no signal: `abort` must reach the model call (run/compaction symmetry).
|
|
648
|
-
//
|
|
649
|
-
// pi builds the controller that makes it abortable AFTER an internal await, so an abort
|
|
650
|
-
// arriving in that window would find nothing to cancel and the compaction would run to
|
|
651
|
-
// completion — the client's cancel silently doing nothing. The intent is latched and
|
|
652
|
-
// re-applied until it takes (`isCompacting` reports when it has).
|
|
653
|
-
//
|
|
654
|
-
// The retry is DEFENSIVE: the window is one await wide, and the test below lands after
|
|
655
|
-
// it, so this loop is not what makes that test pass. It is here because the window is on
|
|
656
|
-
// the code path, not because it has been observed.
|
|
657
|
-
let aborted = false;
|
|
658
|
-
let running = true; // cleared when the compaction settles, however it settles
|
|
659
|
-
const applyAbort = async () => {
|
|
660
|
-
// WAIT for the controller rather than requiring it: an abort that arrives before pi
|
|
661
|
-
// builds one sees isCompacting false, and a loop that only runs WHILE compacting would
|
|
662
|
-
// exit immediately — leaving the intent unapplied in exactly the window it exists for.
|
|
663
|
-
for (let attempt = 0; attempt < 200 && running; attempt++) {
|
|
664
|
-
if (bound.isCompacting) {
|
|
665
|
-
bound.abortCompaction();
|
|
666
|
-
if (!bound.isCompacting)
|
|
667
|
-
return; // it took
|
|
668
|
-
}
|
|
669
|
-
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
670
|
-
}
|
|
671
|
-
};
|
|
672
|
-
compacting.set(session, {
|
|
673
|
-
abort: () => {
|
|
674
|
-
aborted = true;
|
|
675
|
-
bound.abortCompaction();
|
|
676
|
-
void applyAbort();
|
|
677
|
-
},
|
|
678
|
-
});
|
|
679
|
-
emitOwn(session, { type: "compaction_started", timestamp: Date.now(), data: {} });
|
|
680
|
-
void (async () => {
|
|
681
|
-
let outcome;
|
|
682
|
-
// Retries are otherwise invisible between compaction_started and _finished — surface
|
|
683
|
-
// each backoff so a long gap is diagnosable (not confusable with a hang): as a session
|
|
684
|
-
// event for attached observers, as a warn for server logs.
|
|
685
|
-
const unsub = bound.subscribe((event) => {
|
|
686
|
-
if (event.type !== "summarization_retry_scheduled")
|
|
687
|
-
return;
|
|
688
|
-
log.warn(`[fastagent] compaction retry ${event.attempt}/${event.maxAttempts} in ${event.delayMs}ms (session ${session}): ${event.errorMessage}`);
|
|
689
|
-
emitOwn(session, {
|
|
690
|
-
type: "retry_scheduled",
|
|
691
|
-
timestamp: Date.now(),
|
|
692
|
-
data: {
|
|
693
|
-
operation: "compaction",
|
|
694
|
-
attempt: event.attempt,
|
|
695
|
-
maxAttempts: event.maxAttempts,
|
|
696
|
-
delayMs: event.delayMs,
|
|
697
|
-
error: event.errorMessage,
|
|
698
|
-
},
|
|
699
|
-
});
|
|
700
|
-
});
|
|
701
|
-
try {
|
|
702
|
-
const done = await bound.compact(command.instructions);
|
|
703
|
-
outcome = { summary: done.summary };
|
|
704
|
-
}
|
|
705
|
-
catch (error) {
|
|
706
|
-
// A deliberate stop is not a failure — run/compaction symmetry with
|
|
707
|
-
// run_settled{aborted}: the intent is the classification, same discipline as run
|
|
708
|
-
// abort attribution (a racing real failure still reads as aborted).
|
|
709
|
-
outcome = aborted ? { aborted: true } : { error: String(error) };
|
|
710
|
-
}
|
|
711
|
-
running = false;
|
|
712
|
-
unsub();
|
|
713
|
-
teardown();
|
|
714
|
-
// Release BEFORE emitting finished: a watcher seeing finished may dispatch next —
|
|
715
|
-
// "finished ⇒ the lease is free and status is no longer compacting" must hold.
|
|
716
|
-
compacting.delete(session);
|
|
717
|
-
release();
|
|
718
|
-
emitOwn(session, { type: "compaction_finished", timestamp: Date.now(), data: outcome });
|
|
719
|
-
})();
|
|
720
|
-
return { ok: true };
|
|
721
|
-
}
|
|
722
|
-
try {
|
|
723
|
-
// The WRITE handle is opened UNDER the lease: a handle from before tryAcquire could
|
|
724
|
-
// be a stale snapshot of a run that completed in the window — appending to it would
|
|
725
|
-
// hang the override off an outdated leaf.
|
|
726
|
-
const fresh = await sessions.openIfExists(session);
|
|
727
|
-
if (!fresh) {
|
|
728
|
-
// Same real condition as the pre-lease check (the session vanished in the window):
|
|
729
|
-
// same code, same disposition — not a retryable internal error.
|
|
730
|
-
return {
|
|
731
|
-
ok: false,
|
|
732
|
-
error: {
|
|
733
|
-
code: NO_SUCH_SESSION_CODE,
|
|
734
|
-
message: `session "${session}" does not exist — sessions are created by invoke, not by boundary mutations`,
|
|
735
|
-
retryable: false,
|
|
736
|
-
},
|
|
737
|
-
};
|
|
738
|
-
}
|
|
739
|
-
// Unreachable by construction: only set_model/set_thinking/navigate reach this branch,
|
|
740
|
-
// and all three assign `apply` in validation. Throw rather than silently skip (fail visibly).
|
|
741
|
-
if (!apply)
|
|
742
|
-
throw new Error("apply unset outside the compact branch (dispatch invariant broken)");
|
|
743
|
-
emitOwn(session, await apply(fresh));
|
|
744
|
-
}
|
|
745
|
-
catch (error) {
|
|
746
|
-
// The append failed before anything durable landed — "nothing took effect"; the same
|
|
747
|
-
// command may succeed on retry.
|
|
748
|
-
return {
|
|
749
|
-
ok: false,
|
|
750
|
-
error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
|
|
751
|
-
};
|
|
752
|
-
}
|
|
753
|
-
finally {
|
|
754
|
-
release();
|
|
755
|
-
}
|
|
756
|
-
return { ok: true };
|
|
552
|
+
catch (error) {
|
|
553
|
+
// Already durable, so an unresolvable pair must NOT read as "nothing took effect": report
|
|
554
|
+
// the position without it and let the next invoke — which walks the same path — be where
|
|
555
|
+
// the fault surfaces.
|
|
556
|
+
log.warn(`[fastagent] session ${session}: updated, settings unresolvable: ${String(error)}`);
|
|
757
557
|
}
|
|
758
|
-
default:
|
|
759
|
-
// Wire input bypasses the TS union (a remote client can send any `type`): a protocol-
|
|
760
|
-
// level answer, never an undefined body — the transport promises `ok: false` shapes.
|
|
761
|
-
return {
|
|
762
|
-
ok: false,
|
|
763
|
-
error: {
|
|
764
|
-
code: INVALID_COMMAND_CODE,
|
|
765
|
-
message: `unknown command type "${String(command.type)}"`,
|
|
766
|
-
retryable: false,
|
|
767
|
-
},
|
|
768
|
-
};
|
|
769
558
|
}
|
|
559
|
+
emitOwn(session, {
|
|
560
|
+
type: "state_changed",
|
|
561
|
+
timestamp: Date.now(),
|
|
562
|
+
data: {
|
|
563
|
+
...(applied.landed.includes("leafEntryId") ? { leafEntryId: applied.leafEntryId } : {}),
|
|
564
|
+
...(settings && applied.landed.some((f) => f !== "name")
|
|
565
|
+
? { model: `${settings.model.provider}/${settings.model.id}`, thinkingLevel: settings.thinkingLevel }
|
|
566
|
+
: {}),
|
|
567
|
+
...(applied.landed.includes("name") && applied.name ? { name: applied.name } : {}),
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
if (applied.failure !== undefined) {
|
|
572
|
+
// `boundary_command_failed` means nothing durable landed. When something did, the client needs
|
|
573
|
+
// a different sentence — and the fields, so it knows what its retry would repeat.
|
|
574
|
+
return applied.landed.length === 0
|
|
575
|
+
? failed(applied.failure)
|
|
576
|
+
: {
|
|
577
|
+
ok: false,
|
|
578
|
+
error: {
|
|
579
|
+
code: PARTIAL_UPDATE_CODE,
|
|
580
|
+
message: `applied ${applied.landed.join(", ")}, then failed: ${String(applied.failure)} — read state() before retrying`,
|
|
581
|
+
retryable: false,
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
return { ok: true };
|
|
586
|
+
};
|
|
587
|
+
/**
|
|
588
|
+
* ACCEPT-FAST compaction: a full model call (tens of seconds is normal), so holding the call open
|
|
589
|
+
* until it finishes would make acceptance = outcome — the one exception to §5.2, and what broke
|
|
590
|
+
* remote clients whose request timeouts are sized for control calls. This answers once the work is
|
|
591
|
+
* ADMITTED (lease held, session bound); the outcome travels as
|
|
592
|
+
* `compaction_finished{summary|error|aborted}`.
|
|
593
|
+
*
|
|
594
|
+
* Admission is everything cheap and local: binding the session (the ONE canonical resolution of
|
|
595
|
+
* overrides + auth) plus the compaction PREPARATION, a pure branch read. The boundary between
|
|
596
|
+
* "reject" and "the outcome travels as an event" sits where the work becomes asynchronous and
|
|
597
|
+
* expensive: the model call. "Nothing to compact" is therefore a pre-acceptance answer, never a
|
|
598
|
+
* finished{error} dressed as a failure — pi reports it as a throw from compact(), too late.
|
|
599
|
+
*/
|
|
600
|
+
const compactOf = async (session, instructions) => {
|
|
601
|
+
const b = boundary?.();
|
|
602
|
+
if (!b)
|
|
603
|
+
return unsupported("compact()");
|
|
604
|
+
const existing = await sessions.openIfExists(session);
|
|
605
|
+
if (!existing)
|
|
606
|
+
return noSuchSession(session);
|
|
607
|
+
const release = b.lease.tryAcquire(session);
|
|
608
|
+
if (!release)
|
|
609
|
+
return busy();
|
|
610
|
+
let bound;
|
|
611
|
+
try {
|
|
612
|
+
bound = await b.sessionFactory(session);
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
release();
|
|
616
|
+
return failed(error);
|
|
617
|
+
}
|
|
618
|
+
const teardown = () => {
|
|
619
|
+
try {
|
|
620
|
+
bound.dispose();
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
log.warn(`[fastagent] compaction session teardown failed: ${String(error)}`);
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
try {
|
|
627
|
+
// The SAME settings pi will use inside compact(): asking with different thresholds would
|
|
628
|
+
// either reject a compaction pi would have run, or admit one it refuses — and its refusal
|
|
629
|
+
// arrives too late to be a pre-acceptance answer.
|
|
630
|
+
const path = bound.sessionManager.getBranch();
|
|
631
|
+
const prep = prepareCompaction(path, bound.settingsManager.getCompactionSettings());
|
|
632
|
+
if (!prep.ok)
|
|
633
|
+
throw prep.error;
|
|
634
|
+
// Empty is the same answer as absent: pi ships two prepareCompaction implementations
|
|
635
|
+
// (agent-core answers with a Result, coding-agent with undefined) and they disagree on which
|
|
636
|
+
// one an unsummarizable session gets. What they agree on is the CONTENT — no messages to
|
|
637
|
+
// summarize — so that is what the gate reads.
|
|
638
|
+
if (!prep.value || prep.value.messagesToSummarize.length === 0) {
|
|
639
|
+
teardown();
|
|
640
|
+
release();
|
|
641
|
+
// A no-op, not a failure — its OWN code (the NO_ACTIVE_RUN pattern): a client must
|
|
642
|
+
// machine-distinguish "give up" from "call again once the session grows", and branching on
|
|
643
|
+
// message prose is forbidden by contract.
|
|
644
|
+
return {
|
|
645
|
+
ok: false,
|
|
646
|
+
error: {
|
|
647
|
+
code: NOTHING_TO_COMPACT_CODE,
|
|
648
|
+
message: "nothing to compact — the session has no compactable history yet; retry after more turns",
|
|
649
|
+
retryable: false,
|
|
650
|
+
},
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
catch (error) {
|
|
655
|
+
teardown();
|
|
656
|
+
release();
|
|
657
|
+
return failed(error);
|
|
658
|
+
}
|
|
659
|
+
// The door is the session's own compaction abort — a real one, unlike a summarization call with
|
|
660
|
+
// no signal: `abort` must reach the model call (run/compaction symmetry).
|
|
661
|
+
//
|
|
662
|
+
// pi builds the controller that makes it abortable AFTER an internal await, so an abort arriving
|
|
663
|
+
// in that window would find nothing to cancel and the compaction would run to completion — the
|
|
664
|
+
// client's cancel silently doing nothing. The intent is latched and re-applied until it takes
|
|
665
|
+
// (`isCompacting` reports when it has).
|
|
666
|
+
//
|
|
667
|
+
// The retry is DEFENSIVE: the window is one await wide, and the test below lands after it, so
|
|
668
|
+
// this loop is not what makes that test pass. It is here because the window is on the code path,
|
|
669
|
+
// not because it has been observed.
|
|
670
|
+
let aborted = false;
|
|
671
|
+
let running = true; // cleared when the compaction settles, however it settles
|
|
672
|
+
const applyAbort = async () => {
|
|
673
|
+
// WAIT for the controller rather than requiring it: an abort that arrives before pi builds one
|
|
674
|
+
// sees isCompacting false, and a loop that only runs WHILE compacting would exit immediately —
|
|
675
|
+
// leaving the intent unapplied in exactly the window it exists for.
|
|
676
|
+
for (let attempt = 0; attempt < 200 && running; attempt++) {
|
|
677
|
+
if (bound.isCompacting) {
|
|
678
|
+
bound.abortCompaction();
|
|
679
|
+
if (!bound.isCompacting)
|
|
680
|
+
return; // it took
|
|
681
|
+
}
|
|
682
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
compacting.set(session, {
|
|
686
|
+
abort: () => {
|
|
687
|
+
aborted = true;
|
|
688
|
+
bound.abortCompaction();
|
|
689
|
+
void applyAbort();
|
|
690
|
+
},
|
|
691
|
+
});
|
|
692
|
+
emitOwn(session, { type: "compaction_started", timestamp: Date.now(), data: {} });
|
|
693
|
+
void (async () => {
|
|
694
|
+
let outcome;
|
|
695
|
+
// Retries are otherwise invisible between compaction_started and _finished — surface each
|
|
696
|
+
// backoff so a long gap is diagnosable (not confusable with a hang): as a session event for
|
|
697
|
+
// attached observers, as a warn for server logs.
|
|
698
|
+
const unsub = bound.subscribe((event) => {
|
|
699
|
+
if (event.type !== "summarization_retry_scheduled")
|
|
700
|
+
return;
|
|
701
|
+
log.warn(`[fastagent] compaction retry ${event.attempt}/${event.maxAttempts} in ${event.delayMs}ms (session ${session}): ${event.errorMessage}`);
|
|
702
|
+
emitOwn(session, {
|
|
703
|
+
type: "retry_scheduled",
|
|
704
|
+
timestamp: Date.now(),
|
|
705
|
+
data: {
|
|
706
|
+
operation: "compaction",
|
|
707
|
+
attempt: event.attempt,
|
|
708
|
+
maxAttempts: event.maxAttempts,
|
|
709
|
+
delayMs: event.delayMs,
|
|
710
|
+
error: event.errorMessage,
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
try {
|
|
715
|
+
const done = await bound.compact(instructions);
|
|
716
|
+
outcome = { summary: done.summary };
|
|
717
|
+
}
|
|
718
|
+
catch (error) {
|
|
719
|
+
// A deliberate stop is not a failure — run/compaction symmetry with run_settled{aborted}:
|
|
720
|
+
// the intent is the classification, same discipline as run abort attribution (a racing real
|
|
721
|
+
// failure still reads as aborted).
|
|
722
|
+
outcome = aborted ? { aborted: true } : { error: String(error) };
|
|
723
|
+
}
|
|
724
|
+
running = false;
|
|
725
|
+
unsub();
|
|
726
|
+
teardown();
|
|
727
|
+
// Release BEFORE emitting finished: a watcher seeing finished may act next — "finished ⇒ the
|
|
728
|
+
// lease is free and status is no longer compacting" must hold.
|
|
729
|
+
compacting.delete(session);
|
|
730
|
+
release();
|
|
731
|
+
emitOwn(session, { type: "compaction_finished", timestamp: Date.now(), data: outcome });
|
|
732
|
+
})();
|
|
733
|
+
return { ok: true };
|
|
734
|
+
};
|
|
735
|
+
/**
|
|
736
|
+
* Copy a history into a new session. IDEMPOTENT by construction: `into` is the caller's id, so a
|
|
737
|
+
* repeat of a fork that already landed answers `ok: true` and writes nothing — a client retrying a
|
|
738
|
+
* request whose response it never saw does not get a second record. Provenance is what makes that
|
|
739
|
+
* safe rather than merely quiet: the same `into` naming a session that came from somewhere else is
|
|
740
|
+
* a rejection, not an overwrite.
|
|
741
|
+
*/
|
|
742
|
+
const forkOf = async (options) => {
|
|
743
|
+
const { from, at, into } = options;
|
|
744
|
+
/** WHICH fork this is: source + branch point. Two forks of one session at different entries are
|
|
745
|
+
* different requests, so a retry of one must not be answered by the other. */
|
|
746
|
+
const provenance = `${from}@${at}`;
|
|
747
|
+
const b = boundary?.();
|
|
748
|
+
if (!b)
|
|
749
|
+
return unsupported("fork()");
|
|
750
|
+
// An id no client could then open: the empty string, `.` and `..` are not URL path segments
|
|
751
|
+
// (isAddressableSession), so minting one would put a row in list() that nothing can address —
|
|
752
|
+
// listed, unopenable by the client that just listed it.
|
|
753
|
+
if (!isAddressableSession(into)) {
|
|
754
|
+
return invalid(`${JSON.stringify(into)} cannot be a session id — the control plane could not address it`);
|
|
755
|
+
}
|
|
756
|
+
const source = await sessions.openIfExists(from);
|
|
757
|
+
if (!source)
|
|
758
|
+
return noSuchSession(from);
|
|
759
|
+
// The entry predicate is the one `entries()` publishes by, so "everything published is forkable"
|
|
760
|
+
// holds by construction — the same argument the leaf move makes.
|
|
761
|
+
const entry = source.getEntry(at);
|
|
762
|
+
if (!entry || !isNavigable(entry)) {
|
|
763
|
+
return invalid(`entry "${at}" is not a forkable position in session "${from}" — entries() lists the ids`);
|
|
764
|
+
}
|
|
765
|
+
const existingTarget = await sessions.openIfExists(into);
|
|
766
|
+
if (existingTarget) {
|
|
767
|
+
// Already forked from HERE: the request already happened, so answering ok is the truth rather
|
|
768
|
+
// than a convenience. Anything else under that id is a different history, and saying yes would
|
|
769
|
+
// be the id lying about what it holds.
|
|
770
|
+
return forkProvenance(existingTarget) === provenance
|
|
771
|
+
? { ok: true }
|
|
772
|
+
: invalid(`session "${into}" already exists with a different history — fork mints nothing over it`);
|
|
773
|
+
}
|
|
774
|
+
// BOTH ends. The source lease keeps the copy from reading a history a run is mid-write on; the
|
|
775
|
+
// destination lease closes the window the existence check above leaves open — an invoke creating
|
|
776
|
+
// `into`, or a second fork from a DIFFERENT source (whose source lease is another key entirely),
|
|
777
|
+
// otherwise lands between that check and this write. tryAcquire never blocks, so taking two
|
|
778
|
+
// cannot deadlock.
|
|
779
|
+
const release = b.lease.tryAcquire(from);
|
|
780
|
+
if (!release)
|
|
781
|
+
return busy();
|
|
782
|
+
const releaseInto = b.lease.tryAcquire(into);
|
|
783
|
+
if (!releaseInto) {
|
|
784
|
+
release();
|
|
785
|
+
return busy();
|
|
786
|
+
}
|
|
787
|
+
try {
|
|
788
|
+
// Holding the lease is not the same as having looked: re-asked under it, as the update path
|
|
789
|
+
// re-opens its record, so an id taken inside the window is a payload error rather than a
|
|
790
|
+
// store failure the client would read as retryable.
|
|
791
|
+
const raced = await sessions.openIfExists(into);
|
|
792
|
+
if (raced) {
|
|
793
|
+
return forkProvenance(raced) === provenance
|
|
794
|
+
? { ok: true }
|
|
795
|
+
: invalid(`session "${into}" already exists with a different history — fork mints nothing over it`);
|
|
796
|
+
}
|
|
797
|
+
await sessions.fork(from, at, into, provenance);
|
|
798
|
+
}
|
|
799
|
+
catch (error) {
|
|
800
|
+
// Nothing durable landed: the copy is staged and published by rename.
|
|
801
|
+
return failed(error);
|
|
802
|
+
}
|
|
803
|
+
finally {
|
|
804
|
+
releaseInto();
|
|
805
|
+
release();
|
|
806
|
+
}
|
|
807
|
+
return { ok: true };
|
|
808
|
+
};
|
|
809
|
+
const deleteOf = async (session) => {
|
|
810
|
+
const b = boundary?.();
|
|
811
|
+
if (!b)
|
|
812
|
+
return unsupported("delete()");
|
|
813
|
+
const existing = await sessions.openIfExists(session);
|
|
814
|
+
if (!existing)
|
|
815
|
+
return noSuchSession(session);
|
|
816
|
+
// The same lease as a run: a delete racing one would pull the record out from under it.
|
|
817
|
+
const release = b.lease.tryAcquire(session);
|
|
818
|
+
if (!release)
|
|
819
|
+
return busy();
|
|
820
|
+
try {
|
|
821
|
+
// It was there before the lease and is gone now — the same real condition the check above
|
|
822
|
+
// answers, so the same code.
|
|
823
|
+
if (!(await sessions.delete(session)))
|
|
824
|
+
return noSuchSession(session);
|
|
825
|
+
}
|
|
826
|
+
catch (error) {
|
|
827
|
+
// A delete that throws left the record in place.
|
|
828
|
+
return failed(error);
|
|
829
|
+
}
|
|
830
|
+
finally {
|
|
831
|
+
release();
|
|
832
|
+
}
|
|
833
|
+
// The session is gone, so its live streams have nothing left to report: end them rather than
|
|
834
|
+
// hold connections open on a record that no longer exists. A client's reconnect then reads an
|
|
835
|
+
// empty `state()`, which is the truth.
|
|
836
|
+
for (const sub of [...(subscribers.get(session) ?? [])])
|
|
837
|
+
sub.close();
|
|
838
|
+
subscribers.delete(session);
|
|
839
|
+
return { ok: true };
|
|
840
|
+
};
|
|
841
|
+
const control = {
|
|
842
|
+
capabilities: reads.capabilities,
|
|
843
|
+
commands: reads.commands,
|
|
844
|
+
sessions: {
|
|
845
|
+
// The one read that may REJECT (design §13): `[]` is what a deployment with no sessions
|
|
846
|
+
// answers, so a store that cannot be read must not borrow that shape. The transport turns the
|
|
847
|
+
// throw into a coded non-2xx; nothing here swallows it.
|
|
848
|
+
list: () => sessions.list(),
|
|
849
|
+
fork: forkOf,
|
|
850
|
+
// A PURE BINDING: an id and the closures above it. Nothing is checked here — the calls answer
|
|
851
|
+
// that, each in its own vocabulary — and nothing is cached, so two handles for one id are
|
|
852
|
+
// interchangeable.
|
|
853
|
+
get: (session) => ({
|
|
854
|
+
id: session,
|
|
855
|
+
state: () => reads.state(session),
|
|
856
|
+
entries: (options) => reads.entries(session, options),
|
|
857
|
+
events: () => reads.events(session),
|
|
858
|
+
update: (patch) => updateOf(session, patch),
|
|
859
|
+
steer: (prompt) => runAction(session, { type: "steer", prompt }),
|
|
860
|
+
followUp: (prompt) => runAction(session, { type: "follow_up", prompt }),
|
|
861
|
+
abort: () => runAction(session, { type: "abort" }),
|
|
862
|
+
compact: (options) => compactOf(session, options?.instructions),
|
|
863
|
+
delete: () => deleteOf(session),
|
|
864
|
+
}),
|
|
770
865
|
},
|
|
771
866
|
};
|
|
772
867
|
return { control, observer };
|