@gajae-code/coding-agent 0.12.18 → 0.12.20
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/CHANGELOG.md +26 -9
- package/dist/types/commands/gc.d.ts +4 -0
- package/dist/types/commands/sdk.d.ts +66 -0
- package/dist/types/config/settings-schema.d.ts +34 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +97 -1
- package/dist/types/modes/acp/acp-agent.d.ts +33 -1
- package/dist/types/modes/components/bash-execution.d.ts +7 -0
- package/dist/types/modes/components/eval-execution.d.ts +7 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +10 -1
- package/dist/types/sdk/acp/adapter.d.ts +9 -17
- package/dist/types/sdk/acp/index.d.ts +1 -1
- package/dist/types/sdk/broker/broker.d.ts +93 -1
- package/dist/types/sdk/broker/ensure.d.ts +17 -0
- package/dist/types/sdk/broker/index.d.ts +1 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +10 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +81 -2
- package/dist/types/sdk/broker/startup-budget.d.ts +44 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +11 -1
- package/dist/types/sdk/bus/discord-daemon.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +20 -9
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -6
- package/dist/types/sdk/bus/slack-daemon.d.ts +2 -2
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -2
- package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
- package/dist/types/sdk/host/session-runtime.d.ts +4 -0
- package/dist/types/sdk/lifecycle-session.d.ts +13 -2
- package/dist/types/sdk/prompt-failure.d.ts +13 -0
- package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
- package/dist/types/sdk/session-reconnect.d.ts +20 -0
- package/dist/types/sdk/startup-capability.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +14 -4
- package/dist/types/session/blob-store.d.ts +32 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
- package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
- package/dist/types/session/session-manager.d.ts +301 -27
- package/dist/types/session/session-storage.d.ts +170 -0
- package/dist/types/task/executor.d.ts +2 -1
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tui/output-block.d.ts +6 -0
- package/package.json +8 -8
- package/src/cli/args.ts +75 -34
- package/src/commands/acp.ts +7 -1
- package/src/commands/gc.ts +31 -2
- package/src/commands/sdk.ts +141 -7
- package/src/config/settings-schema.ts +43 -0
- package/src/coordinator-mcp/server.ts +6 -1
- package/src/export/html/index.ts +83 -39
- package/src/gjc-runtime/gc-runtime.ts +1283 -4
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/internal-urls/local-protocol.ts +14 -0
- package/src/main.ts +40 -9
- package/src/modes/acp/acp-agent.ts +564 -37
- package/src/modes/acp/terminal-auth.ts +2 -2
- package/src/modes/components/bash-execution.ts +14 -0
- package/src/modes/components/eval-execution.ts +14 -0
- package/src/modes/controllers/command-controller.ts +63 -8
- package/src/modes/utils/ui-helpers.ts +81 -11
- package/src/sdk/acp/adapter.ts +46 -45
- package/src/sdk/acp/index.ts +1 -1
- package/src/sdk/broker/broker.ts +392 -11
- package/src/sdk/broker/ensure.ts +131 -61
- package/src/sdk/broker/index.ts +1 -0
- package/src/sdk/broker/lifecycle-ledger.ts +59 -5
- package/src/sdk/broker/lifecycle.ts +278 -63
- package/src/sdk/broker/startup-budget.ts +103 -0
- package/src/sdk/bus/chat-daemon-control.ts +9 -3
- package/src/sdk/bus/chat-daemon-runtime.ts +565 -29
- package/src/sdk/bus/conversation-store.ts +1 -1
- package/src/sdk/bus/discord-daemon.ts +2 -1
- package/src/sdk/bus/discord-live-provider.ts +34 -6
- package/src/sdk/bus/index.ts +118 -20
- package/src/sdk/bus/kind-aware-reconciliation.ts +2 -1
- package/src/sdk/bus/prompt-reconciliation.ts +4 -9
- package/src/sdk/bus/slack-daemon.ts +91 -18
- package/src/sdk/bus/telegram-daemon-contract.ts +4 -2
- package/src/sdk/bus/topic-registry.ts +7 -1
- package/src/sdk/cli/session-cli.ts +8 -1
- package/src/sdk/host/query/handlers.ts +1 -1
- package/src/sdk/host/query/revision-store.ts +48 -12
- package/src/sdk/host/session-runtime.test.ts +50 -0
- package/src/sdk/host/session-runtime.ts +12 -2
- package/src/sdk/lifecycle-session.ts +26 -3
- package/src/sdk/mcp/server.ts +6 -1
- package/src/sdk/prompt-failure.ts +42 -0
- package/src/sdk/prompt-watchdog.ts +162 -0
- package/src/sdk/session-reconnect.ts +43 -0
- package/src/sdk/startup-capability.ts +13 -4
- package/src/session/agent-session.ts +525 -222
- package/src/session/blob-store.ts +89 -0
- package/src/session/internal/managed-session-scope.ts +36 -26
- package/src/session/internal/managed-session-storage.ts +303 -32
- package/src/session/internal/session-memory-sidecar.ts +2160 -0
- package/src/session/session-manager.ts +7027 -394
- package/src/session/session-storage.ts +1134 -11
- package/src/task/executor.ts +17 -4
- package/src/tools/bash.ts +109 -7
- package/src/tools/find.ts +55 -26
- package/src/tools/todo-contract.ts +40 -8
- package/src/tui/output-block.ts +11 -1
|
@@ -578,7 +578,10 @@ export class TopicRegistry {
|
|
|
578
578
|
: {}),
|
|
579
579
|
...(isValidBindingString(raw.archiveHostId) ? { archiveHostId: raw.archiveHostId } : {}),
|
|
580
580
|
...(isValidBindingGeneration(raw.archiveLeaseEpoch) ? { archiveLeaseEpoch: raw.archiveLeaseEpoch } : {}),
|
|
581
|
-
...(
|
|
581
|
+
...(raw.authorityState === "disconnect_grace" &&
|
|
582
|
+
!fenceSupersedesRecord &&
|
|
583
|
+
typeof raw.disconnectGraceExpiresAt === "number" &&
|
|
584
|
+
Number.isFinite(raw.disconnectGraceExpiresAt)
|
|
582
585
|
? { disconnectGraceExpiresAt: raw.disconnectGraceExpiresAt }
|
|
583
586
|
: {}),
|
|
584
587
|
...(bindingMalformed ? { bindingMalformed: true as const } : {}),
|
|
@@ -1179,6 +1182,9 @@ export class TopicRegistry {
|
|
|
1179
1182
|
} else {
|
|
1180
1183
|
record.authorityEpoch = snapshot.authorityEpoch;
|
|
1181
1184
|
record.authorityState = snapshot.authorityState;
|
|
1185
|
+
if (snapshot.authorityState === "disconnect_grace" && snapshot.record?.disconnectGraceExpiresAt !== undefined)
|
|
1186
|
+
record.disconnectGraceExpiresAt = snapshot.record.disconnectGraceExpiresAt;
|
|
1187
|
+
else delete record.disconnectGraceExpiresAt;
|
|
1182
1188
|
this.rebuildInboundRoutes();
|
|
1183
1189
|
}
|
|
1184
1190
|
if (snapshot.fenceEpoch === undefined) this.epochs.delete(snapshot.sessionId);
|
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
|
|
|
2
2
|
import { createInterface } from "node:readline/promises";
|
|
3
3
|
import { getAgentDir } from "@gajae-code/utils";
|
|
4
4
|
import { ensureBroker } from "../broker/ensure";
|
|
5
|
+
import { lifecycleRequestTimeoutMs } from "../broker/startup-budget";
|
|
5
6
|
import {
|
|
6
7
|
listSdkSessionEndpoints,
|
|
7
8
|
readSdkBrokerDiscovery,
|
|
@@ -242,7 +243,13 @@ export async function runSdkSessionCli(
|
|
|
242
243
|
throw new SdkSessionCliError("invalid_input", "--idempotency-key is required for lifecycle operations.", 2);
|
|
243
244
|
const client = await connectBroker(agentDir);
|
|
244
245
|
try {
|
|
245
|
-
|
|
246
|
+
const timeoutMs = lifecycleRequestTimeoutMs(operation, input);
|
|
247
|
+
writeOutput(
|
|
248
|
+
await client.global(operation, input, {
|
|
249
|
+
idempotencyKey,
|
|
250
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
246
253
|
} finally {
|
|
247
254
|
await client.close();
|
|
248
255
|
}
|
|
@@ -405,7 +405,7 @@ export class QueryHandlers {
|
|
|
405
405
|
const range =
|
|
406
406
|
itemId === undefined
|
|
407
407
|
? await this.revisions.readStringRange(kind, id, revision, field, offset, TARGET_PAGE_BYTES)
|
|
408
|
-
: await this.revisions.
|
|
408
|
+
: await this.revisions.readIndexedFieldRange(kind, id, revision, itemId, field, offset, TARGET_PAGE_BYTES);
|
|
409
409
|
if (!range) return this.#error(request, "resource_gone");
|
|
410
410
|
return this.#chunkRange(request, kind, id, revision, range, selector, {
|
|
411
411
|
field,
|
|
@@ -312,8 +312,7 @@ export class RevisionStore {
|
|
|
312
312
|
readonly #onReadRange?: (start: number, end: number) => void;
|
|
313
313
|
#closing = false;
|
|
314
314
|
#closePromise: Promise<void> | undefined;
|
|
315
|
-
#
|
|
316
|
-
#writesDrained: PromiseWithResolvers<void> | undefined;
|
|
315
|
+
readonly #pendingWrites = new Set<Promise<void>>();
|
|
317
316
|
|
|
318
317
|
constructor(
|
|
319
318
|
readonly sessionId: string,
|
|
@@ -326,10 +325,11 @@ export class RevisionStore {
|
|
|
326
325
|
|
|
327
326
|
createRevision(resourceKind: string, resourceId: string, payload: unknown): Promise<string> {
|
|
328
327
|
if (this.#closing) return Promise.reject(new RevisionStoreError("resource_gone", "snapshot store is closing"));
|
|
329
|
-
|
|
328
|
+
const settled = Promise.withResolvers<void>();
|
|
329
|
+
this.#pendingWrites.add(settled.promise);
|
|
330
330
|
return this.#createRevision(resourceKind, resourceId, payload).finally(() => {
|
|
331
|
-
this.#
|
|
332
|
-
|
|
331
|
+
this.#pendingWrites.delete(settled.promise);
|
|
332
|
+
settled.resolve();
|
|
333
333
|
});
|
|
334
334
|
}
|
|
335
335
|
|
|
@@ -468,6 +468,25 @@ export class RevisionStore {
|
|
|
468
468
|
const range = item?.fields?.[field];
|
|
469
469
|
return range ? this.#readStringRange(revision, range, offset, length) : undefined;
|
|
470
470
|
}
|
|
471
|
+
async readIndexedFieldRange(
|
|
472
|
+
resourceKind: string,
|
|
473
|
+
resourceId: string,
|
|
474
|
+
id: string,
|
|
475
|
+
itemId: string,
|
|
476
|
+
field: string,
|
|
477
|
+
offset: number,
|
|
478
|
+
length: number,
|
|
479
|
+
): Promise<{ body: string; complete: boolean; offset: number } | undefined> {
|
|
480
|
+
const revision = this.#resources.get(`${resourceKind}:${resourceId}`)?.find(item => item.id === id);
|
|
481
|
+
if (!revision) return undefined;
|
|
482
|
+
revision.lastAccessed = this.now();
|
|
483
|
+
const item = revision.index?.items?.find(candidate => candidate.entryId === itemId);
|
|
484
|
+
const range = item?.fields?.[field];
|
|
485
|
+
if (!range) return undefined;
|
|
486
|
+
return range.isString
|
|
487
|
+
? this.#readStringRange(revision, range, offset, length)
|
|
488
|
+
: this.#readJsonRange(revision, range, offset, length);
|
|
489
|
+
}
|
|
471
490
|
async describeIndexedItem(
|
|
472
491
|
resourceKind: string,
|
|
473
492
|
resourceId: string,
|
|
@@ -479,9 +498,7 @@ export class RevisionStore {
|
|
|
479
498
|
if (!item) return undefined;
|
|
480
499
|
return {
|
|
481
500
|
itemId: item.entryId,
|
|
482
|
-
fields: Object.
|
|
483
|
-
.filter(([, range]) => range.isString)
|
|
484
|
-
.map(([field]) => field),
|
|
501
|
+
fields: Object.keys(item.fields ?? {}),
|
|
485
502
|
};
|
|
486
503
|
}
|
|
487
504
|
async readTranscriptBodyRange(
|
|
@@ -531,10 +548,7 @@ export class RevisionStore {
|
|
|
531
548
|
if (this.#closePromise) return this.#closePromise;
|
|
532
549
|
this.#closing = true;
|
|
533
550
|
this.#closePromise = (async () => {
|
|
534
|
-
|
|
535
|
-
this.#writesDrained ??= Promise.withResolvers<void>();
|
|
536
|
-
await this.#writesDrained.promise;
|
|
537
|
-
}
|
|
551
|
+
await Promise.all(this.#pendingWrites);
|
|
538
552
|
this.#resources.clear();
|
|
539
553
|
this.#pinIndex.clear();
|
|
540
554
|
this.#memoryBytes = 0;
|
|
@@ -801,6 +815,28 @@ export class RevisionStore {
|
|
|
801
815
|
return { isString: true, stringIndex };
|
|
802
816
|
}
|
|
803
817
|
|
|
818
|
+
async #readJsonRange(
|
|
819
|
+
revision: Revision,
|
|
820
|
+
range: IndexedField,
|
|
821
|
+
offset: number,
|
|
822
|
+
length: number,
|
|
823
|
+
): Promise<{ body: string; complete: boolean; offset: number } | undefined> {
|
|
824
|
+
if (offset < 0 || length <= 0) return undefined;
|
|
825
|
+
const bytes = range.end - range.start;
|
|
826
|
+
const requestedStart = Math.min(offset, bytes);
|
|
827
|
+
const source = await this.#readBytes(
|
|
828
|
+
revision,
|
|
829
|
+
range.start + requestedStart,
|
|
830
|
+
range.start + Math.min(bytes, requestedStart + length + 7),
|
|
831
|
+
);
|
|
832
|
+
const skipped = utf8ContinuationPrefixLength(source);
|
|
833
|
+
const start = requestedStart + skipped;
|
|
834
|
+
const body = source.subarray(skipped);
|
|
835
|
+
const end = utf8BoundaryAtOrBefore(body, Math.min(body.length, length));
|
|
836
|
+
if (end === 0 && start < bytes) return undefined;
|
|
837
|
+
return { body: body.subarray(0, end).toString("utf8"), complete: start + end === bytes, offset: start };
|
|
838
|
+
}
|
|
839
|
+
|
|
804
840
|
async #readStringRange(
|
|
805
841
|
revision: Revision,
|
|
806
842
|
range: IndexedField,
|
|
@@ -3,6 +3,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import {
|
|
6
|
+
createInvocationReconciliation,
|
|
6
7
|
createSdkSessionRuntimeExtension,
|
|
7
8
|
SessionSdkSessionRuntime,
|
|
8
9
|
type SessionSdkTransport,
|
|
@@ -64,6 +65,55 @@ function extensionContext(sessionId: string, cwd: string): any {
|
|
|
64
65
|
};
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
test("preserves an agent failure code in host prompt reconciliation", async () => {
|
|
69
|
+
const reconciliation = createInvocationReconciliation();
|
|
70
|
+
const correlation = { commandId: "failed-command", turnId: "failed-turn" };
|
|
71
|
+
await reconciliation.noteAccepted("prompt", correlation, "failed-ref");
|
|
72
|
+
await reconciliation.noteTransition("prompt", correlation, {
|
|
73
|
+
type: "agent_failed",
|
|
74
|
+
error: Object.assign(new Error("provider unavailable"), { code: "provider_unavailable" }),
|
|
75
|
+
});
|
|
76
|
+
expect(reconciliation.lookup("prompt", { clientRef: "failed-ref" })).toMatchObject({
|
|
77
|
+
status: "failed",
|
|
78
|
+
error: { code: "provider_unavailable", message: "Prompt submission failed." },
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("redacts a persisted host failure during hydration", async () => {
|
|
83
|
+
const stateRoot = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-reconciliation-"));
|
|
84
|
+
const sessionId = "hydrated-failure";
|
|
85
|
+
try {
|
|
86
|
+
await Bun.write(
|
|
87
|
+
path.join(stateRoot, ".sdk-reconciliation", `${sessionId}.json`),
|
|
88
|
+
JSON.stringify({
|
|
89
|
+
version: 1,
|
|
90
|
+
sessionId,
|
|
91
|
+
records: [
|
|
92
|
+
{
|
|
93
|
+
kind: "prompt",
|
|
94
|
+
commandId: "persisted-command",
|
|
95
|
+
turnId: "persisted-turn",
|
|
96
|
+
status: "failed",
|
|
97
|
+
acceptedAt: 1,
|
|
98
|
+
terminalAt: Date.now(),
|
|
99
|
+
error: { code: "unsafe code!", message: "secret provider payload" },
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
const reconciliation = createInvocationReconciliation({ stateRoot, sessionId });
|
|
105
|
+
await reconciliation.hydrate();
|
|
106
|
+
expect(
|
|
107
|
+
reconciliation.lookup("prompt", { commandId: "persisted-command", turnId: "persisted-turn" }),
|
|
108
|
+
).toMatchObject({
|
|
109
|
+
status: "failed",
|
|
110
|
+
error: { code: "internal", message: "Prompt submission failed." },
|
|
111
|
+
});
|
|
112
|
+
} finally {
|
|
113
|
+
await rm(stateRoot, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
67
117
|
describe("SessionSdkSessionRuntime", () => {
|
|
68
118
|
test("has no notification adapter or native notification import edge", async () => {
|
|
69
119
|
const source = await readFile(new URL("./session-runtime.ts", import.meta.url), "utf8");
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
syntheticNamespaceCollision,
|
|
23
23
|
} from "../model-profile-model";
|
|
24
24
|
import { projectQ10Models } from "../models.js";
|
|
25
|
+
import { formatPromptFailureForLocalLog, sanitizePromptFailure } from "../prompt-failure";
|
|
25
26
|
import { OPERATIONS } from "../protocol/operation-registry";
|
|
26
27
|
import { type ControlSurface, dispatchControl } from "./control";
|
|
27
28
|
import { SessionSdkHost, type SessionSdkHostOptions } from "./host";
|
|
@@ -265,7 +266,7 @@ export interface InvocationReconciliation {
|
|
|
265
266
|
hydrate(): Promise<void>;
|
|
266
267
|
}
|
|
267
268
|
|
|
268
|
-
function createInvocationReconciliation(
|
|
269
|
+
export function createInvocationReconciliation(
|
|
269
270
|
options: { stateRoot?: string; sessionId?: string } = {},
|
|
270
271
|
): InvocationReconciliation {
|
|
271
272
|
const ACTIVE_CAPACITY = 256;
|
|
@@ -361,6 +362,7 @@ function createInvocationReconciliation(
|
|
|
361
362
|
record.terminalAt = Date.now();
|
|
362
363
|
record.error = { code: "process_restart", message: "Reconciliation incomplete after process restart." };
|
|
363
364
|
}
|
|
365
|
+
if (record.status === "failed") record.error = sanitizePromptFailure(record.error);
|
|
364
366
|
records.set(key(record.kind, record), { ...record });
|
|
365
367
|
}
|
|
366
368
|
}
|
|
@@ -416,7 +418,15 @@ function createInvocationReconciliation(
|
|
|
416
418
|
} else {
|
|
417
419
|
record.status = frame.type === "agent_failed" ? "failed" : "terminal_ok";
|
|
418
420
|
record.terminalAt = Date.now();
|
|
419
|
-
if (frame.type === "agent_failed")
|
|
421
|
+
if (frame.type === "agent_failed") {
|
|
422
|
+
logger.error("SDK invocation failed", {
|
|
423
|
+
kind,
|
|
424
|
+
commandId: correlation.commandId,
|
|
425
|
+
turnId: correlation.turnId,
|
|
426
|
+
error: formatPromptFailureForLocalLog(frame.error),
|
|
427
|
+
});
|
|
428
|
+
record.error = sanitizePromptFailure(frame.error);
|
|
429
|
+
}
|
|
420
430
|
}
|
|
421
431
|
await persist();
|
|
422
432
|
},
|
|
@@ -13,11 +13,22 @@ export type CreateLifecycleAgentSessionResult =
|
|
|
13
13
|
session: AgentSession;
|
|
14
14
|
capability: SdkStartupCapability;
|
|
15
15
|
rollback: SdkStartupRollbackTracker;
|
|
16
|
+
/**
|
|
17
|
+
* Starts the memory backend that construction deliberately skipped. The
|
|
18
|
+
* caller MUST run it only after readiness is published.
|
|
19
|
+
*/
|
|
20
|
+
startDeferredMemoryBackend: () => Promise<void>;
|
|
16
21
|
}
|
|
17
22
|
| { capability: SdkStartupCapability; rollback: SdkStartupRollbackTracker; failure: SdkStartupFailure };
|
|
18
23
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Options accepted by lifecycle-only session construction.
|
|
26
|
+
*
|
|
27
|
+
* `deferMemoryBackendStartup` is not accepted: lifecycle sessions are launched
|
|
28
|
+
* by the broker under a fixed readiness deadline, and memory startup runs
|
|
29
|
+
* unbounded LLM work, so deferral is an invariant rather than a caller choice.
|
|
30
|
+
*/
|
|
31
|
+
export type CreateLifecycleAgentSessionOptions = Omit<CreateAgentSessionOptions, "deferMemoryBackendStartup"> & {
|
|
21
32
|
/**
|
|
22
33
|
* Startup budget for ACP lifecycle MCP launches, in milliseconds. Set only
|
|
23
34
|
* when the lifecycle request supplies `mcpServers`; ordinary consumers keep
|
|
@@ -42,6 +53,11 @@ export async function createLifecycleAgentSession(
|
|
|
42
53
|
const { mcpStartupTimeoutMs, readiness: _readiness, ...sessionOptions } = options;
|
|
43
54
|
const internalOptions = {
|
|
44
55
|
...sessionOptions,
|
|
56
|
+
// Memory startup (rollout summarisation) issues one LLM request per
|
|
57
|
+
// claimed rollout, so its duration scales with the backlog. Keeping it
|
|
58
|
+
// inside the broker's readiness window is what kills the child at the
|
|
59
|
+
// cutoff; the host resumes it once readiness is published.
|
|
60
|
+
deferMemoryBackendStartup: true,
|
|
45
61
|
[lifecycleStartupCapabilityOption]: capability,
|
|
46
62
|
...(mcpStartupTimeoutMs !== undefined ? { [lifecycleMcpStartupTimeoutOption]: mcpStartupTimeoutMs } : {}),
|
|
47
63
|
} as CreateAgentSessionOptions & {
|
|
@@ -51,7 +67,14 @@ export async function createLifecycleAgentSession(
|
|
|
51
67
|
const result = await createAgentSession(internalOptions);
|
|
52
68
|
if (!result.session.extensionRunner)
|
|
53
69
|
capability.settleFailure(capability.normalizeFailure("registration", "runner_absent"));
|
|
54
|
-
|
|
70
|
+
if (!result.startDeferredMemoryBackend)
|
|
71
|
+
throw new Error("Lifecycle session construction did not return a deferred memory backend starter.");
|
|
72
|
+
return {
|
|
73
|
+
session: result.session,
|
|
74
|
+
capability,
|
|
75
|
+
rollback,
|
|
76
|
+
startDeferredMemoryBackend: result.startDeferredMemoryBackend,
|
|
77
|
+
};
|
|
55
78
|
} catch (error) {
|
|
56
79
|
const settled = capability.settleFailure(capability.normalizeFailure("registration", "failed", error));
|
|
57
80
|
const failure =
|
package/src/sdk/mcp/server.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { getAgentDir } from "@gajae-code/utils";
|
|
3
3
|
import { ensureBroker } from "../broker/ensure";
|
|
4
|
+
import { lifecycleRequestTimeoutMs } from "../broker/startup-budget";
|
|
4
5
|
import { SdkClient, SdkClientError } from "../client/client";
|
|
5
6
|
import {
|
|
6
7
|
listSdkSessionEndpoints,
|
|
@@ -253,7 +254,11 @@ export function createSdkMcpServer(options: SdkMcpServerOptions = {}) {
|
|
|
253
254
|
const broker = await readSdkBrokerDiscovery(agentDir);
|
|
254
255
|
if (!broker) return { ok: false, error: { code: "not_found", message: "SDK broker not found" } };
|
|
255
256
|
client = await connect(broker.url, broker.token);
|
|
256
|
-
const
|
|
257
|
+
const timeoutMs = lifecycleRequestTimeoutMs(operation, input);
|
|
258
|
+
const response = await client.global(operation, input, {
|
|
259
|
+
idempotencyKey,
|
|
260
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
261
|
+
});
|
|
257
262
|
return isLifecycleOperation(operation) ? redactLifecycleCredentials(response) : response;
|
|
258
263
|
} catch (error) {
|
|
259
264
|
return resultError(error);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe failure shaping shared by SDK transports and reconciliation stores.
|
|
3
|
+
* Provider error text is retained only in the local diagnostic log; wire and
|
|
4
|
+
* persisted reconciliation details expose a fixed redacted message.
|
|
5
|
+
*/
|
|
6
|
+
export const PROMPT_FAILURE_CODE_MAX = 64;
|
|
7
|
+
const LOCAL_FAILURE_LOG_MAX = 16_384;
|
|
8
|
+
|
|
9
|
+
/** Safe-token code capped at 64; arbitrary failure text is never retained. */
|
|
10
|
+
export function sanitizePromptFailure(error: unknown): { code: string; message: string } {
|
|
11
|
+
let rawCode = "";
|
|
12
|
+
try {
|
|
13
|
+
const candidate = error as { code?: unknown } | undefined;
|
|
14
|
+
rawCode = typeof candidate?.code === "string" ? candidate.code : "";
|
|
15
|
+
} catch {
|
|
16
|
+
// Untrusted error records may expose throwing accessors.
|
|
17
|
+
}
|
|
18
|
+
const code = rawCode.length <= PROMPT_FAILURE_CODE_MAX && /^[A-Za-z0-9._-]+$/.test(rawCode) ? rawCode : "internal";
|
|
19
|
+
return { code, message: "Prompt submission failed." };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Best-effort local diagnostic text that never crosses the SDK boundary. */
|
|
23
|
+
export function formatPromptFailureForLocalLog(error: unknown): string {
|
|
24
|
+
try {
|
|
25
|
+
let detail: string;
|
|
26
|
+
if (error instanceof Error) {
|
|
27
|
+
const stack = error.stack;
|
|
28
|
+
detail = typeof stack === "string" ? stack : error.message;
|
|
29
|
+
} else if (typeof error === "string") detail = error;
|
|
30
|
+
else if (error !== null && typeof error === "object") {
|
|
31
|
+
const candidate = error as { code?: unknown; message?: unknown };
|
|
32
|
+
const code = typeof candidate.code === "string" ? candidate.code : undefined;
|
|
33
|
+
const message = typeof candidate.message === "string" ? candidate.message : undefined;
|
|
34
|
+
detail =
|
|
35
|
+
[code, message].filter((value): value is string => value !== undefined).join(": ") ||
|
|
36
|
+
"<object prompt failure>";
|
|
37
|
+
} else detail = String(error);
|
|
38
|
+
return detail.length <= LOCAL_FAILURE_LOG_MAX ? detail : `${detail.slice(0, LOCAL_FAILURE_LOG_MAX)}…`;
|
|
39
|
+
} catch {
|
|
40
|
+
return "<unserializable prompt failure>";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { TOOL_TIMEOUTS } from "../tools/tool-timeouts";
|
|
2
|
+
import { HEARTBEAT_TTL_MS } from "./bus/daemon-paths";
|
|
3
|
+
|
|
4
|
+
/** Ceiling a caller may *request* for one tool call; `bash`/`ssh` cap out at 3600s. */
|
|
5
|
+
const MAX_TOOL_RUNTIME_MS = Math.max(...Object.values(TOOL_TIMEOUTS).map(config => config.max)) * 1_000;
|
|
6
|
+
|
|
7
|
+
/** Budget one tool call gets when nobody asks to extend it; `bash` defaults to 300s. */
|
|
8
|
+
const DEFAULT_TOOL_RUNTIME_MS = Math.max(...Object.values(TOOL_TIMEOUTS).map(config => config.default)) * 1_000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Frame-free cost of one transport round trip while the producer is still healthy:
|
|
12
|
+
* the owner heartbeat has to lapse ({@link HEARTBEAT_TTL_MS}) before a reconnect is
|
|
13
|
+
* even attempted, and the reconnect itself gets a second window.
|
|
14
|
+
*/
|
|
15
|
+
const SDK_RECONNECT_BUDGET_MS = 2 * HEARTBEAT_TTL_MS;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Inactivity bound for a prompt whose session host has no tool call in flight: after
|
|
19
|
+
* this much silence the host is treated as having stopped producing, so the prompt is
|
|
20
|
+
* settled instead of hanging the client forever.
|
|
21
|
+
*
|
|
22
|
+
* Every step of a live turn emits a frame (`agent_start`, message events, tool events,
|
|
23
|
+
* `agent_end`), so this bound only has to exceed the longest frame-free gap a healthy
|
|
24
|
+
* turn can produce *with nothing executing*: one model round trip, from the request
|
|
25
|
+
* leaving the host to the first streamed event coming back, possibly straddling a
|
|
26
|
+
* transport reconnect.
|
|
27
|
+
*
|
|
28
|
+
* `TOOL_TIMEOUTS` is this runtime's own statement of how long a single unattended step
|
|
29
|
+
* may block before it is declared stuck, and its slowest **default**
|
|
30
|
+
* ({@link DEFAULT_TOOL_RUNTIME_MS}) is the longest such wait nobody explicitly opted
|
|
31
|
+
* into. That is well above the agent's own model-stream watchdog (120s idle / 100s
|
|
32
|
+
* first event in `packages/ai/src/utils/idle-iterator.ts`), which aborts a stalled
|
|
33
|
+
* provider stream and republishes it as an error frame — itself a frame — long before
|
|
34
|
+
* this bound. {@link SDK_RECONNECT_BUDGET_MS} is added on top so a reconnect landing
|
|
35
|
+
* inside that gap cannot trip it either. Two providers widen their first-event
|
|
36
|
+
* fallback past this bound (alibaba-token-plan 600s, kimi-code 300s); a turn that
|
|
37
|
+
* emits nothing at all for this long on those is reported as abandoned rather than
|
|
38
|
+
* left `running`, and the session still accepts the next prompt.
|
|
39
|
+
*
|
|
40
|
+
* {@link MAX_TOOL_RUNTIME_MS} is deliberately *not* used here. `max` is what a caller
|
|
41
|
+
* may request, not what tools take, so sizing every gap for it produced a 63min bound
|
|
42
|
+
* that could never act as a safety net. Turns that legitimately reach that ceiling are
|
|
43
|
+
* covered by {@link ACP_PROMPT_TOOL_ACTIVITY_TIMEOUT_MS} instead, which is gated on
|
|
44
|
+
* evidence that a tool is actually executing.
|
|
45
|
+
*/
|
|
46
|
+
export const ACP_PROMPT_INACTIVITY_TIMEOUT_MS = DEFAULT_TOOL_RUNTIME_MS + SDK_RECONNECT_BUDGET_MS;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Inactivity bound that applies only while the session host has an unfinished tool
|
|
50
|
+
* call — a `tool_execution_start`/`tool_execution_update` with no matching
|
|
51
|
+
* `tool_execution_end`.
|
|
52
|
+
*
|
|
53
|
+
* A long `bash` is protected by the evidence that it is running, not by a constant
|
|
54
|
+
* sized for the worst case, so this wide bound stands only for as long as that
|
|
55
|
+
* evidence does. It still terminates: no tool call may outlive
|
|
56
|
+
* {@link MAX_TOOL_RUNTIME_MS}, after which the host must publish `tool_execution_end`,
|
|
57
|
+
* so a host that dies mid-tool is still reported (one reconnect budget later).
|
|
58
|
+
*/
|
|
59
|
+
export const ACP_PROMPT_TOOL_ACTIVITY_TIMEOUT_MS = MAX_TOOL_RUNTIME_MS + SDK_RECONNECT_BUDGET_MS;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Widest frame-free window a single provider call may legitimately occupy before the
|
|
63
|
+
* agent's own stream watchdog aborts it. Mirrors `ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS`
|
|
64
|
+
* in `packages/ai/src/utils/idle-iterator.ts` (600s), the largest per-provider first-event
|
|
65
|
+
* fallback of any built-in provider — `DEFAULT_STREAM_FIRST_EVENT_TIMEOUT_MS` is 100s and
|
|
66
|
+
* `kimi-code` gets 300s. That constant is module-private there, so it is mirrored here and
|
|
67
|
+
* pinned against `getProviderFirstEventTimeoutFallbackMs` in the watchdog test: a change on
|
|
68
|
+
* either side fails loudly instead of silently inflating this bound.
|
|
69
|
+
*/
|
|
70
|
+
const MAX_PROVIDER_FIRST_EVENT_TIMEOUT_MS = 600_000;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Inactivity bound that applies while a prompt is awaiting the model: a provider call is in
|
|
74
|
+
* flight and has not produced the first frame of its response yet.
|
|
75
|
+
*
|
|
76
|
+
* Tool execution had evidence and model inference did not, so a turn thinking after
|
|
77
|
+
* `agent_start` looked exactly like a dead producer and was killed at
|
|
78
|
+
* {@link ACP_PROMPT_INACTIVITY_TIMEOUT_MS}. Inference gets the same evidence-based
|
|
79
|
+
* tolerance: the wide bound stands only as long as a model call is observably unanswered.
|
|
80
|
+
*
|
|
81
|
+
* It still terminates. A provider stream that yields nothing within
|
|
82
|
+
* {@link MAX_PROVIDER_FIRST_EVENT_TIMEOUT_MS} is aborted by the agent's own stream watchdog,
|
|
83
|
+
* which republishes as an error/retry frame, so a host that dies mid-inference is still
|
|
84
|
+
* reported one reconnect budget later.
|
|
85
|
+
*/
|
|
86
|
+
export const ACP_PROMPT_INFERENCE_TIMEOUT_MS = MAX_PROVIDER_FIRST_EVENT_TIMEOUT_MS + SDK_RECONNECT_BUDGET_MS;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Per-prompt view of what the session host is observably doing: which tool calls it has
|
|
90
|
+
* started but not finished, and whether it is waiting on a model call. Inbound frames are
|
|
91
|
+
* the only evidence the ACP side has, so "a tool is running" means exactly "a start was
|
|
92
|
+
* observed and its end was not", and "awaiting the model" means exactly "a model call was
|
|
93
|
+
* dispatched and nothing from its response has been observed".
|
|
94
|
+
*/
|
|
95
|
+
export class PromptActivity {
|
|
96
|
+
readonly #running = new Set<string>();
|
|
97
|
+
#awaitingModel = false;
|
|
98
|
+
|
|
99
|
+
/** Folds one inbound frame's lifecycle event into the in-flight tool and model state. */
|
|
100
|
+
observe(eventType: string | undefined, toolCallId: string | undefined, messageRole: string | undefined): void {
|
|
101
|
+
this.#observeInference(eventType, messageRole);
|
|
102
|
+
if (toolCallId === undefined) return;
|
|
103
|
+
if (eventType === "tool_execution_start" || eventType === "tool_execution_update") this.#running.add(toolCallId);
|
|
104
|
+
else if (eventType === "tool_execution_end") this.#running.delete(toolCallId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A turn is awaiting the model from every point where the host dispatches a provider
|
|
109
|
+
* call — `agent_start` for the turn's first call, `tool_execution_end` for the
|
|
110
|
+
* re-invocation that carries a tool result back — until the first frame of that
|
|
111
|
+
* response proves the model answered.
|
|
112
|
+
*/
|
|
113
|
+
#observeInference(eventType: string | undefined, messageRole: string | undefined): void {
|
|
114
|
+
if (eventType === "agent_start" || eventType === "tool_execution_end") {
|
|
115
|
+
this.#awaitingModel = true;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// Streamed content/thinking/tool-call deltas, and the tool call the model asked for,
|
|
119
|
+
// are only ever produced by an answering model.
|
|
120
|
+
if (eventType === "message_update" || eventType === "tool_execution_start") {
|
|
121
|
+
this.#awaitingModel = false;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// Providers that do not stream emit no `message_update`, so the assistant message
|
|
125
|
+
// itself is the first proof. `message_start`/`message_end` also carry the user and
|
|
126
|
+
// tool-result messages the host echoes back, which prove nothing about the model.
|
|
127
|
+
if ((eventType === "message_start" || eventType === "message_end") && messageRole === "assistant")
|
|
128
|
+
this.#awaitingModel = false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** True while at least one started tool call has not reported an end. */
|
|
132
|
+
get running(): boolean {
|
|
133
|
+
return this.#running.size > 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** True while a dispatched model call has not produced the first frame of its response. */
|
|
137
|
+
get awaitingModel(): boolean {
|
|
138
|
+
return this.#awaitingModel;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Bound that applies to the next frame-free gap, given what is observably executing. */
|
|
142
|
+
get inactivityBoundMs(): number {
|
|
143
|
+
if (this.running) return ACP_PROMPT_TOOL_ACTIVITY_TIMEOUT_MS;
|
|
144
|
+
return this.#awaitingModel ? ACP_PROMPT_INFERENCE_TIMEOUT_MS : ACP_PROMPT_INACTIVITY_TIMEOUT_MS;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Timer surface behind the prompt watchdog; tests substitute a virtual clock. */
|
|
149
|
+
export interface PromptWatchdogClock {
|
|
150
|
+
now(): number;
|
|
151
|
+
/** Runs `handler` after `delayMs`; the returned callback cancels the pending run. */
|
|
152
|
+
schedule(handler: () => void, delayMs: number): () => void;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export const systemPromptWatchdogClock: PromptWatchdogClock = {
|
|
156
|
+
now: () => Date.now(),
|
|
157
|
+
schedule(handler: () => void, delayMs: number): () => void {
|
|
158
|
+
const timer = setTimeout(handler, delayMs);
|
|
159
|
+
timer.unref?.();
|
|
160
|
+
return () => clearTimeout(timer);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { HEARTBEAT_TTL_MS } from "./bus/daemon-paths";
|
|
2
|
+
import type { SdkClientOptions } from "./client";
|
|
3
|
+
|
|
4
|
+
type SessionReconnectOptions = Required<
|
|
5
|
+
Pick<SdkClientOptions, "reconnectAttempts" | "reconnectBackoffMs" | "reconnectMaxBackoffMs">
|
|
6
|
+
>;
|
|
7
|
+
|
|
8
|
+
const SESSION_RECONNECT_BACKOFF_MS = 250;
|
|
9
|
+
const SESSION_RECONNECT_MAX_BACKOFF_MS = 2_000;
|
|
10
|
+
/** Cover twice the host heartbeat TTL so a reaped stall still has room to recover. */
|
|
11
|
+
const SESSION_RECONNECT_BUDGET_MS = 2 * HEARTBEAT_TTL_MS;
|
|
12
|
+
|
|
13
|
+
function attemptsCovering(budgetMs: number): number {
|
|
14
|
+
let elapsed = 0;
|
|
15
|
+
let attempts = 0;
|
|
16
|
+
while (elapsed <= budgetMs) {
|
|
17
|
+
elapsed += Math.min(SESSION_RECONNECT_BACKOFF_MS * 2 ** attempts, SESSION_RECONNECT_MAX_BACKOFF_MS);
|
|
18
|
+
attempts++;
|
|
19
|
+
}
|
|
20
|
+
return attempts;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reconnect budget for every long-lived SDK session client: ACP sessions and the
|
|
25
|
+
* chat daemon's attached sessions alike. Named for the ACP session that first
|
|
26
|
+
* needed it; it lives here because both live under the same host reaper and the
|
|
27
|
+
* bus layer must not import the ACP layer to reach it.
|
|
28
|
+
*
|
|
29
|
+
* Invariant: the client's total reconnect budget must outlive the host heartbeat
|
|
30
|
+
* TTL ({@link HEARTBEAT_TTL_MS}). The SDK host drops a session whose client has
|
|
31
|
+
* not ponged within that TTL, so a client that gives up reconnecting sooner turns
|
|
32
|
+
* every stall the host reaps into a permanently lost session ("SDK WebSocket
|
|
33
|
+
* reconnect attempts exhausted"). The transport defaults (3 attempts at 25ms base
|
|
34
|
+
* = 175ms total) are correct only for one-shot request clients.
|
|
35
|
+
*
|
|
36
|
+
* Backoff ramps 250 -> 500 -> 1000 and then holds at the 2s cap, so individual
|
|
37
|
+
* sleeps stay short and recovery is prompt once the host answers again.
|
|
38
|
+
*/
|
|
39
|
+
export const ACP_SESSION_RECONNECT: SessionReconnectOptions = {
|
|
40
|
+
reconnectAttempts: attemptsCovering(SESSION_RECONNECT_BUDGET_MS),
|
|
41
|
+
reconnectBackoffMs: SESSION_RECONNECT_BACKOFF_MS,
|
|
42
|
+
reconnectMaxBackoffMs: SESSION_RECONNECT_MAX_BACKOFF_MS,
|
|
43
|
+
};
|
|
@@ -5,7 +5,14 @@ import {
|
|
|
5
5
|
} from "../config/model-profile-contract";
|
|
6
6
|
export type SdkStartupPhase = "registration" | "startup";
|
|
7
7
|
|
|
8
|
-
export type SdkStartupReason =
|
|
8
|
+
export type SdkStartupReason =
|
|
9
|
+
| "disabled"
|
|
10
|
+
| "ineligible"
|
|
11
|
+
| "factory_absent"
|
|
12
|
+
| "runner_absent"
|
|
13
|
+
| "admission_timeout"
|
|
14
|
+
| "pending"
|
|
15
|
+
| "failed";
|
|
9
16
|
|
|
10
17
|
export interface SdkStartupFailure {
|
|
11
18
|
phase: SdkStartupPhase;
|
|
@@ -143,9 +150,11 @@ export function normalizeSdkStartupFailure(
|
|
|
143
150
|
? "SDK startup factory is unavailable."
|
|
144
151
|
: reason === "runner_absent"
|
|
145
152
|
? "SDK startup extension runner is unavailable."
|
|
146
|
-
: reason === "
|
|
147
|
-
? "SDK startup
|
|
148
|
-
:
|
|
153
|
+
: reason === "admission_timeout"
|
|
154
|
+
? "SDK host startup was not admitted before the queue wait cutoff."
|
|
155
|
+
: reason === "pending"
|
|
156
|
+
? "SDK startup did not complete before readiness cutoff."
|
|
157
|
+
: FALLBACK_MESSAGE;
|
|
149
158
|
const message = sanitizeSdkStartupMessage(error, knownSecrets);
|
|
150
159
|
const profileError = isModelProfileError(error) ? { code: error.code, details: error.details } : {};
|
|
151
160
|
return { phase, reason, message: message === FALLBACK_MESSAGE ? fallback : message, ...profileError };
|