@koda-sl/baker-bridge 0.39.3 → 0.39.6
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/README.md +20 -4
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +1 -1
- package/dist/hono/agent.d.ts +18 -4
- package/dist/hono/agent.d.ts.map +1 -1
- package/dist/hono/agent.js +217 -100
- package/dist/hono/agent.js.map +1 -1
- package/dist/hono/agent.test.d.ts +2 -0
- package/dist/hono/agent.test.d.ts.map +1 -0
- package/dist/hono/agent.test.js +209 -0
- package/dist/hono/agent.test.js.map +1 -0
- package/dist/hono/callback.d.ts.map +1 -1
- package/dist/hono/callback.js +5 -2
- package/dist/hono/callback.js.map +1 -1
- package/dist/hono/convex.d.ts +13 -11
- package/dist/hono/convex.d.ts.map +1 -1
- package/dist/hono/convex.js +92 -55
- package/dist/hono/convex.js.map +1 -1
- package/dist/hono/convex.test.js +63 -2
- package/dist/hono/convex.test.js.map +1 -1
- package/dist/hono/server.d.ts.map +1 -1
- package/dist/hono/server.js +3 -0
- package/dist/hono/server.js.map +1 -1
- package/dist/hono/session-store.d.ts +1 -0
- package/dist/hono/session-store.d.ts.map +1 -1
- package/dist/hono/session-store.js +9 -1
- package/dist/hono/session-store.js.map +1 -1
- package/dist/hono/types.d.ts +23 -0
- package/dist/hono/types.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@ The bridge picks a Claude model per turn before calling the Agent SDK:
|
|
|
72
72
|
| `GET` | `/landings/:slug/definition` | None | Return landing `_definition.md` as `{ content, title }` |
|
|
73
73
|
| `GET` | `/flows/:slug` | None | Read flow `_data.json` (decrypted) |
|
|
74
74
|
| `PUT` | `/flows/:slug` | None | Write flow `_data.json` (encrypts at rest) |
|
|
75
|
-
| `POST` | `/message/async` | Bearer token | Dispatch a chat turn — fire-and-forget, 202 immediately |
|
|
75
|
+
| `POST` | `/message/async` | Bearer token | Dispatch a chat turn — fire-and-forget, 202 immediately. Convex must include `turnId` and may include rendered `history` for deterministic rehydration |
|
|
76
76
|
| `POST` | `/message/abort` | Bearer token | Abort a running query — kills the SDK iterator |
|
|
77
77
|
| `POST` | `/answer-question` | Bearer token | Resolve a pending `AskUserQuestion` |
|
|
78
78
|
|
|
@@ -112,7 +112,12 @@ Convex action ──POST /message/async──▶ Bridge AgentSession
|
|
|
112
112
|
- **Stop button** flips the thread to `cancelled` via Convex first (UI is
|
|
113
113
|
freed immediately) and best-effort POSTs `/message/abort` to kill the SDK.
|
|
114
114
|
- **AskUserQuestion** posts `/api/chat/input-request` and blocks until the
|
|
115
|
-
dashboard answers via Convex action → bridge `/answer-question`.
|
|
115
|
+
dashboard answers via Convex action → bridge `/answer-question`. Convex
|
|
116
|
+
persists the answer before proxying it so a bridge restart does not erase the
|
|
117
|
+
user's response.
|
|
118
|
+
- **Context source diagnostics** go to `/api/chat/context` so support can tell
|
|
119
|
+
whether a turn used `local_resume`, `rehydrated_missing_session`, or
|
|
120
|
+
`rehydrated_invalid_resume`.
|
|
116
121
|
|
|
117
122
|
## Attachment validation
|
|
118
123
|
|
|
@@ -147,6 +152,16 @@ oversized images still block the commit.
|
|
|
147
152
|
when no SDK events are flowing. Without it, the 2 min stale-thread cron
|
|
148
153
|
would falsely timeout active threads.
|
|
149
154
|
- The session registry evicts entries idle for >24h to bound memory.
|
|
155
|
+
- Convex dispatches include a bounded rendered current-thread history snapshot.
|
|
156
|
+
The bridge uses the local SDK session id when valid, but falls back to this
|
|
157
|
+
persisted transcript when the session file is missing or invalid; invalid
|
|
158
|
+
resume is retried at most once before surfacing an error. Prior attachment
|
|
159
|
+
metadata is rendered by Convex, capped, and counted against the same
|
|
160
|
+
deterministic history budget.
|
|
161
|
+
- Each dispatched turn carries an explicit `turnId`; bridge callbacks preserve
|
|
162
|
+
that run-scoped id through stream, input, heartbeat, diagnostics, completion,
|
|
163
|
+
and background completion retries so stale callbacks cannot be retagged as a
|
|
164
|
+
newer turn.
|
|
150
165
|
- A new chat that arrives while a previous one is still running aborts the
|
|
151
166
|
previous run with a 30s timeout cap — a hung run cannot block the next message.
|
|
152
167
|
|
|
@@ -165,8 +180,9 @@ returned in the dispatch response besides 202.
|
|
|
165
180
|
## Architecture
|
|
166
181
|
|
|
167
182
|
- **One session per thread** — `getOrCreateSession(threadId)` ensures every
|
|
168
|
-
message on the same thread shares an `AgentSession`. Multi-turn context
|
|
169
|
-
|
|
183
|
+
message on the same thread shares an `AgentSession`. Multi-turn context uses
|
|
184
|
+
`~/.baker/sessions/<threadId>.json` as the fast path and Convex persisted
|
|
185
|
+
current-thread history as the fallback when local continuity is unavailable.
|
|
170
186
|
- **Run-token cancellation** — each call to `sendAndStream` mints a Symbol; if
|
|
171
187
|
the symbol changes (abort, replace) the run cleans up and exits without
|
|
172
188
|
writing further. No globally-mutable abort flag.
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
declare const args: string[];
|
|
3
3
|
declare const command: string;
|
|
4
|
-
declare const VERSION = "0.39.
|
|
4
|
+
declare const VERSION = "0.39.6";
|
|
5
5
|
declare function printUsage(): void;
|
|
6
6
|
declare function main(): Promise<void>;
|
|
7
7
|
//# sourceMappingURL=cli.d.ts.map
|
package/dist/cli.js
CHANGED
package/dist/hono/agent.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { SlashCommand } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
-
import type { ChatAttachment } from "./types.ts";
|
|
2
|
+
import type { ChatAttachment, DispatchHistorySnapshot } from "./types.ts";
|
|
3
|
+
export declare function buildRehydratedPrompt(content: string, history?: DispatchHistorySnapshot): string;
|
|
4
|
+
export declare function isResumeSessionError(error: unknown): boolean;
|
|
3
5
|
declare class AgentSession {
|
|
4
6
|
readonly threadId: string;
|
|
5
7
|
private sessionId;
|
|
6
8
|
private pendingQuestions;
|
|
7
|
-
private handlerCtx;
|
|
8
|
-
private options;
|
|
9
9
|
/** Identity token for the currently-running stream. abort() nulls this; if a
|
|
10
10
|
* later check sees a different value, the run knows it was abandoned. This
|
|
11
11
|
* replaces a global `aborted` flag whose reset-on-entry was racy. */
|
|
@@ -19,18 +19,32 @@ declare class AgentSession {
|
|
|
19
19
|
* assistant event when the SDK doesn't emit one (pure-text, no tool calls). */
|
|
20
20
|
private turnText;
|
|
21
21
|
private postedAssistantEvent;
|
|
22
|
+
private sawSdkEvent;
|
|
22
23
|
lastUsedAt: number;
|
|
23
24
|
constructor(threadId: string);
|
|
24
25
|
/** Cancel any in-flight run. The next status check inside runStream will trip. */
|
|
25
26
|
abort(): void;
|
|
26
27
|
setSessionId(id: string): void;
|
|
27
28
|
resolveQuestion(toolUseId: string, answers: Record<string, string>): boolean;
|
|
28
|
-
sendAndStream(content: string, attachments
|
|
29
|
+
sendAndStream(content: string, attachments: ChatAttachment[] | undefined, history: DispatchHistorySnapshot | undefined, turnId: string): Promise<void>;
|
|
29
30
|
private runStream;
|
|
31
|
+
private startRun;
|
|
32
|
+
private streamPreparedRun;
|
|
33
|
+
private startWatchdog;
|
|
34
|
+
private runWithResumeFallback;
|
|
35
|
+
private recordInitialContextSource;
|
|
36
|
+
private canRetryWithRehydration;
|
|
37
|
+
private recordInvalidResumeFallback;
|
|
38
|
+
private runQueryOnce;
|
|
39
|
+
private consumeSdkMessage;
|
|
40
|
+
private completeSuccessfulRun;
|
|
41
|
+
private postSyntheticAssistantEvent;
|
|
42
|
+
private recordRehydrationFailure;
|
|
30
43
|
private handleStreamEvent;
|
|
31
44
|
private handleContentBlockStart;
|
|
32
45
|
private handleMessage;
|
|
33
46
|
private preparePrompt;
|
|
47
|
+
private selectPreparedPrompt;
|
|
34
48
|
private completeWithCancel;
|
|
35
49
|
private completeWithError;
|
|
36
50
|
}
|
package/dist/hono/agent.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../../src/hono/agent.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAuB,YAAY,EAAE,MAAM,gCAAgC,CAAC;
|
|
1
|
+
{"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../../src/hono/agent.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAuB,YAAY,EAAE,MAAM,gCAAgC,CAAC;AAoBxF,OAAO,KAAK,EAAE,cAAc,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAsB1E,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,uBAAuB,GAAG,MAAM,CAMhG;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAa5D;AAuCD,cAAM,YAAY;IAChB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,OAAO,CAAC,SAAS,CAAqB;IACtC,OAAO,CAAC,gBAAgB,CAAsC;IAE9D;;0EAEsE;IACtE,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,WAAW,CAAgC;IACnD,OAAO,CAAC,gBAAgB,CAA8B;IACtD,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,gBAAgB,CAAuB;IAC/C,OAAO,CAAC,KAAK,CAAqB;IAClC;oFACgF;IAChF,OAAO,CAAC,QAAQ,CAAM;IACtB,OAAO,CAAC,oBAAoB,CAAS;IACrC,OAAO,CAAC,WAAW,CAAS;IAC5B,UAAU,EAAE,MAAM,CAAc;gBAEpB,QAAQ,EAAE,MAAM;IAI5B,kFAAkF;IAClF,KAAK,IAAI,IAAI;IAgBb,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAI9B,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO;IAUtE,aAAa,CACjB,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,cAAc,EAAE,GAAG,SAAS,EACzC,OAAO,EAAE,uBAAuB,GAAG,SAAS,EAC5C,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC;YAoCF,SAAS;IAgCvB,OAAO,CAAC,QAAQ;YAYF,iBAAiB;IAyB/B,OAAO,CAAC,aAAa;YAeP,qBAAqB;IA8CnC,OAAO,CAAC,0BAA0B;IAiClC,OAAO,CAAC,uBAAuB;IAI/B,OAAO,CAAC,2BAA2B;YAcrB,YAAY;YAyBZ,iBAAiB;YAuBjB,qBAAqB;YAYrB,2BAA2B;IAYzC,OAAO,CAAC,wBAAwB;IAchC,OAAO,CAAC,iBAAiB;IAoBzB,OAAO,CAAC,uBAAuB;YAiBjB,aAAa;YAgBb,aAAa;YAiBb,oBAAoB;YAQpB,kBAAkB;YAalB,iBAAiB;CAgBhC;AAkHD,wBAAsB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAahF;AAED,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAOtD;AAWD,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC,CAuBhE"}
|
package/dist/hono/agent.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
2
|
import { buildAttachmentPromptSuffix, downloadAttachments } from "./attachments.js";
|
|
3
|
-
import { clearStreamBuffer, enqueueStreamingTools, enqueueStreamText, flushStream, markStreamReplace, postEvent, postHeartbeat, postInputRequest, postInputResolved, postRouterSample, signalComplete, } from "./convex.js";
|
|
3
|
+
import { clearStreamBuffer, enqueueStreamingTools, enqueueStreamText, flushStream, markStreamReplace, postEvent, postHeartbeat, postInputRequest, postInputResolved, postRouterSample, recordContextSource, signalComplete, } from "./convex.js";
|
|
4
4
|
import { selectModel } from "./model-router.js";
|
|
5
5
|
import { recoverStalledPublish } from "./publish-recovery.js";
|
|
6
|
-
import { loadSessionId, saveSessionId } from "./session-store.js";
|
|
6
|
+
import { clearSessionId, loadSessionId, saveSessionId } from "./session-store.js";
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
// Tunables
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
@@ -22,6 +22,24 @@ const SESSION_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
|
22
22
|
* hangs sooner so users see a real error instead of an indefinite spinner. */
|
|
23
23
|
const ASK_USER_QUESTION_TIMEOUT_MS = 15 * 60 * 1000;
|
|
24
24
|
const HEARTBEAT_INTERVAL_MS = 45_000;
|
|
25
|
+
export function buildRehydratedPrompt(content, history) {
|
|
26
|
+
if (!history?.renderedContext) {
|
|
27
|
+
return content;
|
|
28
|
+
}
|
|
29
|
+
return `${history.renderedContext}\n\n--- Current user request\n${content}`;
|
|
30
|
+
}
|
|
31
|
+
export function isResumeSessionError(error) {
|
|
32
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
33
|
+
if (!message.includes("session") && !message.includes("resume")) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return (message.includes("resume") &&
|
|
37
|
+
(message.includes("not found") ||
|
|
38
|
+
message.includes("invalid") ||
|
|
39
|
+
message.includes("expired") ||
|
|
40
|
+
message.includes("unknown") ||
|
|
41
|
+
message.includes("does not exist")));
|
|
42
|
+
}
|
|
25
43
|
// ---------------------------------------------------------------------------
|
|
26
44
|
// AgentSession — one per thread, persists across messages so SDK resume works.
|
|
27
45
|
// ---------------------------------------------------------------------------
|
|
@@ -29,8 +47,6 @@ class AgentSession {
|
|
|
29
47
|
threadId;
|
|
30
48
|
sessionId;
|
|
31
49
|
pendingQuestions = new Map();
|
|
32
|
-
handlerCtx;
|
|
33
|
-
options;
|
|
34
50
|
/** Identity token for the currently-running stream. abort() nulls this; if a
|
|
35
51
|
* later check sees a different value, the run knows it was abandoned. This
|
|
36
52
|
* replaces a global `aborted` flag whose reset-on-entry was racy. */
|
|
@@ -44,11 +60,10 @@ class AgentSession {
|
|
|
44
60
|
* assistant event when the SDK doesn't emit one (pure-text, no tool calls). */
|
|
45
61
|
turnText = "";
|
|
46
62
|
postedAssistantEvent = false;
|
|
63
|
+
sawSdkEvent = false;
|
|
47
64
|
lastUsedAt = Date.now();
|
|
48
65
|
constructor(threadId) {
|
|
49
66
|
this.threadId = threadId;
|
|
50
|
-
this.handlerCtx = { threadId, pendingQuestions: this.pendingQuestions };
|
|
51
|
-
this.options = buildSessionOptions(this.handlerCtx);
|
|
52
67
|
}
|
|
53
68
|
/** Cancel any in-flight run. The next status check inside runStream will trip. */
|
|
54
69
|
abort() {
|
|
@@ -79,13 +94,14 @@ class AgentSession {
|
|
|
79
94
|
this.pendingQuestions.delete(toolUseId);
|
|
80
95
|
return true;
|
|
81
96
|
}
|
|
82
|
-
async sendAndStream(content, attachments) {
|
|
97
|
+
async sendAndStream(content, attachments, history, turnId) {
|
|
83
98
|
if (this.streamingPromise) {
|
|
99
|
+
const previousPromise = this.streamingPromise;
|
|
84
100
|
this.replacingStream = true;
|
|
85
101
|
this.abort();
|
|
86
102
|
try {
|
|
87
103
|
await Promise.race([
|
|
88
|
-
|
|
104
|
+
previousPromise,
|
|
89
105
|
new Promise((_, reject) => setTimeout(() => reject(new Error("previous stream timed out")), REPLACE_STREAM_TIMEOUT_MS)),
|
|
90
106
|
]);
|
|
91
107
|
}
|
|
@@ -93,123 +109,222 @@ class AgentSession {
|
|
|
93
109
|
console.error(`[AgentSession] previous stream did not drain on thread ${this.threadId} — forcing reset:`, err instanceof Error ? err.message : err);
|
|
94
110
|
}
|
|
95
111
|
finally {
|
|
96
|
-
this.streamingPromise
|
|
112
|
+
if (this.streamingPromise === previousPromise) {
|
|
113
|
+
this.streamingPromise = null;
|
|
114
|
+
}
|
|
97
115
|
this.replacingStream = false;
|
|
98
116
|
}
|
|
99
117
|
}
|
|
100
|
-
|
|
118
|
+
const runPromise = this.runStream(content, attachments, history, turnId);
|
|
119
|
+
this.streamingPromise = runPromise;
|
|
101
120
|
try {
|
|
102
|
-
await
|
|
121
|
+
await runPromise;
|
|
103
122
|
}
|
|
104
123
|
finally {
|
|
105
|
-
this.streamingPromise
|
|
124
|
+
if (this.streamingPromise === runPromise) {
|
|
125
|
+
this.streamingPromise = null;
|
|
126
|
+
}
|
|
106
127
|
}
|
|
107
128
|
}
|
|
108
|
-
async runStream(content, attachments) {
|
|
129
|
+
async runStream(content, attachments, history, turnId) {
|
|
130
|
+
const runToken = this.startRun();
|
|
131
|
+
let result = { costUsd: 0, attemptedRehydration: false };
|
|
132
|
+
try {
|
|
133
|
+
const prepared = await this.preparePrompt(content, attachments, runToken, history);
|
|
134
|
+
if (!prepared) {
|
|
135
|
+
await this.completeWithCancel(0, turnId);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
result = await this.streamPreparedRun(prepared, history, runToken, turnId);
|
|
139
|
+
if (this.activeRun !== runToken) {
|
|
140
|
+
await this.completeWithCancel(result.costUsd, turnId);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
await this.completeSuccessfulRun(result.costUsd, turnId);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (result.attemptedRehydration) {
|
|
147
|
+
this.recordRehydrationFailure(history, error, turnId);
|
|
148
|
+
}
|
|
149
|
+
await this.completeWithError(result.costUsd, error, runToken, turnId);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
startRun() {
|
|
109
153
|
const runToken = Symbol("agent-run");
|
|
110
154
|
this.activeRun = runToken;
|
|
111
155
|
this.lastUsedAt = Date.now();
|
|
112
156
|
this.currentTurnTools = [];
|
|
113
157
|
this.turnText = "";
|
|
114
158
|
this.postedAssistantEvent = false;
|
|
115
|
-
|
|
159
|
+
this.sawSdkEvent = false;
|
|
116
160
|
this.model = undefined;
|
|
161
|
+
return runToken;
|
|
162
|
+
}
|
|
163
|
+
async streamPreparedRun(prepared, history, runToken, turnId) {
|
|
164
|
+
const abortController = new AbortController();
|
|
165
|
+
this.activeAbort = abortController;
|
|
166
|
+
let lastEventAt = Date.now();
|
|
167
|
+
const watchdog = this.startWatchdog(() => lastEventAt, abortController);
|
|
168
|
+
const heartbeat = setInterval(() => void postHeartbeat(this.threadId, turnId), HEARTBEAT_INTERVAL_MS).unref();
|
|
117
169
|
try {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
170
|
+
return await this.runWithResumeFallback(prepared, history, runToken, turnId, abortController, (timestamp) => {
|
|
171
|
+
lastEventAt = timestamp;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
clearInterval(watchdog);
|
|
176
|
+
clearInterval(heartbeat);
|
|
177
|
+
if (this.activeAbort === abortController) {
|
|
178
|
+
this.activeAbort = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
startWatchdog(getLastEventAt, abortController) {
|
|
183
|
+
return setInterval(() => {
|
|
184
|
+
if (Date.now() - getLastEventAt() <= SDK_WATCHDOG_MS) {
|
|
121
185
|
return;
|
|
122
186
|
}
|
|
187
|
+
console.error(`[AgentSession] watchdog: no SDK event for ${SDK_WATCHDOG_MS}ms — aborting thread ${this.threadId}`);
|
|
188
|
+
abortController.abort();
|
|
189
|
+
}, 30_000).unref();
|
|
190
|
+
}
|
|
191
|
+
async runWithResumeFallback(prepared, history, runToken, turnId, abortController, markEvent) {
|
|
192
|
+
const attemptedResumeSessionId = this.sessionId;
|
|
193
|
+
const result = { costUsd: 0, attemptedRehydration: false };
|
|
194
|
+
result.attemptedRehydration = this.recordInitialContextSource(Boolean(attemptedResumeSessionId), history, turnId);
|
|
195
|
+
try {
|
|
123
196
|
this.model = prepared.model;
|
|
124
|
-
|
|
125
|
-
this.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
};
|
|
132
|
-
const q = query({ prompt: prepared.prompt, options: opts });
|
|
133
|
-
let lastEventAt = Date.now();
|
|
134
|
-
const watchdog = setInterval(() => {
|
|
135
|
-
if (Date.now() - lastEventAt > SDK_WATCHDOG_MS) {
|
|
136
|
-
console.error(`[AgentSession] watchdog: no SDK event for ${SDK_WATCHDOG_MS}ms — aborting thread ${this.threadId}`);
|
|
137
|
-
clearInterval(watchdog);
|
|
138
|
-
this.activeAbort?.abort();
|
|
139
|
-
}
|
|
140
|
-
}, 30_000).unref();
|
|
141
|
-
const heartbeat = setInterval(() => {
|
|
142
|
-
void postHeartbeat(this.threadId);
|
|
143
|
-
}, HEARTBEAT_INTERVAL_MS).unref();
|
|
144
|
-
try {
|
|
145
|
-
for await (const msg of q) {
|
|
146
|
-
lastEventAt = Date.now();
|
|
147
|
-
this.lastUsedAt = lastEventAt;
|
|
148
|
-
if (this.activeRun !== runToken) {
|
|
149
|
-
break;
|
|
150
|
-
}
|
|
151
|
-
if (msg.session_id) {
|
|
152
|
-
this.sessionId = msg.session_id;
|
|
153
|
-
void saveSessionId(this.threadId, msg.session_id);
|
|
154
|
-
}
|
|
155
|
-
await this.handleMessage(msg);
|
|
156
|
-
if (msg.type === "result") {
|
|
157
|
-
costUsd = msg.total_cost_usd;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
197
|
+
result.costUsd = await this.runQueryOnce(Boolean(attemptedResumeSessionId), prepared, runToken, turnId, abortController, markEvent);
|
|
198
|
+
void postRouterSample(this.threadId, prepared.model, prepared.routerMessages, turnId);
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (!this.canRetryWithRehydration(attemptedResumeSessionId, error)) {
|
|
203
|
+
throw error;
|
|
160
204
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
205
|
+
this.sessionId = undefined;
|
|
206
|
+
await clearSessionId(this.threadId);
|
|
207
|
+
this.recordInvalidResumeFallback(history, turnId);
|
|
208
|
+
result.attemptedRehydration = true;
|
|
209
|
+
const fallbackPrepared = await this.selectPreparedPrompt(buildRehydratedPrompt(prepared.prompt, history), runToken);
|
|
210
|
+
if (!fallbackPrepared) {
|
|
211
|
+
return result;
|
|
164
212
|
}
|
|
165
|
-
this.
|
|
213
|
+
this.model = fallbackPrepared.model;
|
|
214
|
+
result.costUsd = await this.runQueryOnce(false, fallbackPrepared, runToken, turnId, abortController, markEvent);
|
|
215
|
+
void postRouterSample(this.threadId, fallbackPrepared.model, fallbackPrepared.routerMessages, turnId);
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
recordInitialContextSource(hasResumeSession, history, turnId) {
|
|
220
|
+
if (hasResumeSession) {
|
|
221
|
+
void recordContextSource(this.threadId, "local_resume", {
|
|
222
|
+
historyEntryCount: history?.entries.length ?? 0,
|
|
223
|
+
historyTruncated: history?.truncated ?? false,
|
|
224
|
+
}, turnId);
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
if (!history || (history.entries.length === 0 && history.omittedEntryCount === 0)) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
void recordContextSource(this.threadId, "rehydrated_missing_session", {
|
|
231
|
+
historyEntryCount: history.entries.length,
|
|
232
|
+
omittedEntryCount: history.omittedEntryCount,
|
|
233
|
+
historyTruncated: history.truncated,
|
|
234
|
+
}, turnId);
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
canRetryWithRehydration(attemptedResumeSessionId, error) {
|
|
238
|
+
return Boolean(attemptedResumeSessionId && isResumeSessionError(error) && !this.sawSdkEvent);
|
|
239
|
+
}
|
|
240
|
+
recordInvalidResumeFallback(history, turnId) {
|
|
241
|
+
console.warn(`[AgentSession] clearing invalid local session for thread ${this.threadId} and retrying once`);
|
|
242
|
+
void recordContextSource(this.threadId, "rehydrated_invalid_resume", {
|
|
243
|
+
historyEntryCount: history?.entries.length ?? 0,
|
|
244
|
+
omittedEntryCount: history?.omittedEntryCount ?? 0,
|
|
245
|
+
historyTruncated: history?.truncated ?? false,
|
|
246
|
+
}, turnId);
|
|
247
|
+
}
|
|
248
|
+
async runQueryOnce(useResume, prepared, runToken, turnId, abortController, markEvent) {
|
|
249
|
+
let costUsd = 0;
|
|
250
|
+
const opts = {
|
|
251
|
+
...buildSessionOptions({ threadId: this.threadId, pendingQuestions: this.pendingQuestions, turnId }),
|
|
252
|
+
model: prepared.model,
|
|
253
|
+
abortController,
|
|
254
|
+
...(useResume && this.sessionId && { resume: this.sessionId }),
|
|
255
|
+
};
|
|
256
|
+
const q = query({ prompt: prepared.prompt, options: opts });
|
|
257
|
+
for await (const msg of q) {
|
|
258
|
+
costUsd = await this.consumeSdkMessage(msg, costUsd, runToken, turnId, markEvent);
|
|
166
259
|
if (this.activeRun !== runToken) {
|
|
167
|
-
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
if (this.pendingQuestions.size > 0) {
|
|
171
|
-
console.warn(`[AgentSession] SDK completed but ${String(this.pendingQuestions.size)} question(s) still pending on thread ${this.threadId}`);
|
|
260
|
+
break;
|
|
172
261
|
}
|
|
173
|
-
// The Agent SDK doesn't emit a `type: "assistant"` message for pure-text
|
|
174
|
-
// turns (no tool calls). Synthesize one from the accumulated stream deltas
|
|
175
|
-
// so the response is persisted and visible in the conversation.
|
|
176
|
-
if (!this.postedAssistantEvent && this.turnText.length > 0) {
|
|
177
|
-
const syntheticAssistant = {
|
|
178
|
-
type: "assistant",
|
|
179
|
-
message: {
|
|
180
|
-
role: "assistant",
|
|
181
|
-
content: [{ type: "text", text: this.turnText }],
|
|
182
|
-
stop_reason: "end_turn",
|
|
183
|
-
},
|
|
184
|
-
};
|
|
185
|
-
await postEvent(this.threadId, syntheticAssistant, this.model);
|
|
186
|
-
}
|
|
187
|
-
await signalComplete(this.threadId, costUsd, false, undefined, this.model);
|
|
188
262
|
}
|
|
189
|
-
|
|
190
|
-
|
|
263
|
+
return costUsd;
|
|
264
|
+
}
|
|
265
|
+
async consumeSdkMessage(msg, costUsd, runToken, turnId, markEvent) {
|
|
266
|
+
const timestamp = Date.now();
|
|
267
|
+
markEvent(timestamp);
|
|
268
|
+
this.lastUsedAt = timestamp;
|
|
269
|
+
if (this.activeRun !== runToken) {
|
|
270
|
+
return costUsd;
|
|
271
|
+
}
|
|
272
|
+
this.sawSdkEvent = true;
|
|
273
|
+
if (msg.session_id) {
|
|
274
|
+
this.sessionId = msg.session_id;
|
|
275
|
+
void saveSessionId(this.threadId, msg.session_id);
|
|
276
|
+
}
|
|
277
|
+
await this.handleMessage(msg, turnId);
|
|
278
|
+
return msg.type === "result" ? msg.total_cost_usd : costUsd;
|
|
279
|
+
}
|
|
280
|
+
async completeSuccessfulRun(costUsd, turnId) {
|
|
281
|
+
if (this.pendingQuestions.size > 0) {
|
|
282
|
+
console.warn(`[AgentSession] SDK completed but ${String(this.pendingQuestions.size)} question(s) still pending on thread ${this.threadId}`);
|
|
191
283
|
}
|
|
284
|
+
if (!this.postedAssistantEvent && this.turnText.length > 0) {
|
|
285
|
+
await this.postSyntheticAssistantEvent(turnId);
|
|
286
|
+
}
|
|
287
|
+
await signalComplete(this.threadId, costUsd, false, undefined, this.model, turnId);
|
|
288
|
+
}
|
|
289
|
+
async postSyntheticAssistantEvent(turnId) {
|
|
290
|
+
const syntheticAssistant = {
|
|
291
|
+
type: "assistant",
|
|
292
|
+
message: {
|
|
293
|
+
role: "assistant",
|
|
294
|
+
content: [{ type: "text", text: this.turnText }],
|
|
295
|
+
stop_reason: "end_turn",
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
await postEvent(this.threadId, syntheticAssistant, this.model, turnId);
|
|
192
299
|
}
|
|
193
|
-
|
|
300
|
+
recordRehydrationFailure(history, error, turnId) {
|
|
301
|
+
void recordContextSource(this.threadId, "rehydration_failed", {
|
|
302
|
+
historyEntryCount: history?.entries.length ?? 0,
|
|
303
|
+
omittedEntryCount: history?.omittedEntryCount ?? 0,
|
|
304
|
+
historyTruncated: history?.truncated ?? false,
|
|
305
|
+
reason: error instanceof Error ? error.name : typeof error,
|
|
306
|
+
}, turnId);
|
|
307
|
+
}
|
|
308
|
+
handleStreamEvent(event, turnId) {
|
|
194
309
|
if (event.type === "message_start") {
|
|
195
310
|
this.currentTurnTools = [];
|
|
196
311
|
this.turnText = "";
|
|
197
|
-
markStreamReplace(this.threadId);
|
|
312
|
+
markStreamReplace(this.threadId, turnId);
|
|
198
313
|
return;
|
|
199
314
|
}
|
|
200
315
|
if (event.type === "content_block_start") {
|
|
201
|
-
this.handleContentBlockStart(event);
|
|
316
|
+
this.handleContentBlockStart(event, turnId);
|
|
202
317
|
return;
|
|
203
318
|
}
|
|
204
319
|
if (event.type === "content_block_delta") {
|
|
205
320
|
const delta = event.delta;
|
|
206
321
|
if (delta?.type === "text_delta" && typeof delta.text === "string") {
|
|
207
322
|
this.turnText += delta.text;
|
|
208
|
-
enqueueStreamText(this.threadId, delta.text);
|
|
323
|
+
enqueueStreamText(this.threadId, delta.text, turnId);
|
|
209
324
|
}
|
|
210
325
|
}
|
|
211
326
|
}
|
|
212
|
-
handleContentBlockStart(event) {
|
|
327
|
+
handleContentBlockStart(event, turnId) {
|
|
213
328
|
const block = event.content_block;
|
|
214
329
|
if (block?.type !== "tool_use") {
|
|
215
330
|
return;
|
|
@@ -223,25 +338,25 @@ class AgentSession {
|
|
|
223
338
|
tool.input = block.input;
|
|
224
339
|
}
|
|
225
340
|
this.currentTurnTools = [...this.currentTurnTools, tool];
|
|
226
|
-
enqueueStreamingTools(this.threadId, this.currentTurnTools);
|
|
341
|
+
enqueueStreamingTools(this.threadId, this.currentTurnTools, turnId);
|
|
227
342
|
}
|
|
228
|
-
async handleMessage(msg) {
|
|
343
|
+
async handleMessage(msg, turnId) {
|
|
229
344
|
if (msg.type === "stream_event") {
|
|
230
345
|
const event = msg.event;
|
|
231
346
|
if (event) {
|
|
232
|
-
this.handleStreamEvent(event);
|
|
347
|
+
this.handleStreamEvent(event, turnId);
|
|
233
348
|
}
|
|
234
349
|
return;
|
|
235
350
|
}
|
|
236
351
|
if (msg.type === "assistant") {
|
|
237
352
|
this.currentTurnTools = [];
|
|
238
353
|
this.postedAssistantEvent = true;
|
|
239
|
-
await postEvent(this.threadId, msg, this.model);
|
|
354
|
+
await postEvent(this.threadId, msg, this.model, turnId);
|
|
240
355
|
}
|
|
241
356
|
// system / result messages are not persisted; result.cost_usd is captured above.
|
|
242
357
|
}
|
|
243
|
-
async preparePrompt(content, attachments, runToken) {
|
|
244
|
-
let prompt = content;
|
|
358
|
+
async preparePrompt(content, attachments, runToken, history) {
|
|
359
|
+
let prompt = this.sessionId ? content : buildRehydratedPrompt(content, history);
|
|
245
360
|
if (attachments && attachments.length > 0) {
|
|
246
361
|
const paths = await downloadAttachments(attachments);
|
|
247
362
|
prompt += buildAttachmentPromptSuffix(paths);
|
|
@@ -249,28 +364,30 @@ class AgentSession {
|
|
|
249
364
|
if (this.activeRun !== runToken) {
|
|
250
365
|
return null;
|
|
251
366
|
}
|
|
367
|
+
return this.selectPreparedPrompt(prompt, runToken);
|
|
368
|
+
}
|
|
369
|
+
async selectPreparedPrompt(prompt, runToken) {
|
|
252
370
|
const routerResult = await selectModel(prompt);
|
|
253
371
|
if (this.activeRun !== runToken) {
|
|
254
372
|
return null;
|
|
255
373
|
}
|
|
256
374
|
return { prompt, model: routerResult.model, routerMessages: routerResult.messages };
|
|
257
375
|
}
|
|
258
|
-
async completeWithCancel(costUsd) {
|
|
376
|
+
async completeWithCancel(costUsd, turnId) {
|
|
259
377
|
// Don't fire signalComplete if we're being replaced by another stream — the
|
|
260
378
|
// new stream's signalComplete will run instead, and the cancelThread mutation
|
|
261
379
|
// already flipped the thread. (Convex completeThread is idempotent regardless.)
|
|
262
|
-
await flushStream(this.threadId);
|
|
263
|
-
clearStreamBuffer(this.threadId);
|
|
380
|
+
await flushStream(this.threadId, turnId);
|
|
381
|
+
clearStreamBuffer(this.threadId, turnId);
|
|
264
382
|
if (this.replacingStream) {
|
|
265
383
|
return;
|
|
266
384
|
}
|
|
267
|
-
await signalComplete(this.threadId, costUsd, true, ["Cancelled by user"], this.model);
|
|
385
|
+
await signalComplete(this.threadId, costUsd, true, ["Cancelled by user"], this.model, turnId);
|
|
268
386
|
void recoverStalledPublish();
|
|
269
387
|
}
|
|
270
|
-
async completeWithError(costUsd, error, runToken) {
|
|
271
|
-
this.activeAbort = null;
|
|
388
|
+
async completeWithError(costUsd, error, runToken, turnId) {
|
|
272
389
|
if (this.activeRun !== runToken) {
|
|
273
|
-
await this.completeWithCancel(costUsd);
|
|
390
|
+
await this.completeWithCancel(costUsd, turnId);
|
|
274
391
|
return;
|
|
275
392
|
}
|
|
276
393
|
const rawMessage = error instanceof Error ? error.message : "Unknown error";
|
|
@@ -279,7 +396,7 @@ class AgentSession {
|
|
|
279
396
|
if (this.pendingQuestions.size > 0) {
|
|
280
397
|
console.warn(`[AgentSession] SDK errored with ${String(this.pendingQuestions.size)} question(s) still pending on thread ${this.threadId}`);
|
|
281
398
|
}
|
|
282
|
-
await signalComplete(this.threadId, costUsd, true, [message], this.model);
|
|
399
|
+
await signalComplete(this.threadId, costUsd, true, [message], this.model, turnId);
|
|
283
400
|
void recoverStalledPublish();
|
|
284
401
|
}
|
|
285
402
|
}
|
|
@@ -297,7 +414,7 @@ function enrichImageError(message) {
|
|
|
297
414
|
}
|
|
298
415
|
async function handleAskUserQuestion(input, toolUseID, ctx, signal) {
|
|
299
416
|
console.warn(`[AskUserQuestion] blocking for ${toolUseID} on thread ${ctx.threadId}`);
|
|
300
|
-
const posted = await postInputRequest(ctx.threadId, toolUseID, input.questions);
|
|
417
|
+
const posted = await postInputRequest(ctx.threadId, toolUseID, input.questions, ctx.turnId);
|
|
301
418
|
if (!posted) {
|
|
302
419
|
console.error(`[AskUserQuestion] failed to post input-request for ${toolUseID} — frontend may not show question UI`);
|
|
303
420
|
}
|
|
@@ -327,7 +444,7 @@ async function handleAskUserQuestion(input, toolUseID, ctx, signal) {
|
|
|
327
444
|
}, { once: true });
|
|
328
445
|
});
|
|
329
446
|
console.warn(`[AskUserQuestion] ${toolUseID} answered, resuming`);
|
|
330
|
-
void postInputResolved(ctx.threadId);
|
|
447
|
+
void postInputResolved(ctx.threadId, ctx.turnId);
|
|
331
448
|
return {
|
|
332
449
|
behavior: "allow",
|
|
333
450
|
updatedInput: { questions: input.questions, answers },
|