talon-agent 1.33.0 → 1.34.1
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/package.json +3 -3
- package/src/backend/claude-sdk/handler.ts +13 -0
- package/src/backend/claude-sdk/stream.ts +33 -0
- package/src/bootstrap.ts +5 -1
- package/src/core/agent-runtime/events.ts +4 -0
- package/src/core/engine/gateway.ts +7 -6
- package/src/core/errors.ts +62 -4
- package/src/core/tools/index.ts +29 -0
- package/src/core/tools/messaging.ts +5 -0
- package/src/core/tools/types.ts +11 -0
- package/src/core/weaver/index.ts +9 -2
- package/src/core/weaver/loom.ts +33 -2
- package/src/core/weaver/memory-prefetch.ts +49 -0
- package/src/core/weaver/shuttle.ts +63 -0
- package/src/core/weaver/typing-loop.ts +36 -0
- package/src/core/weaver/warp-resolver.ts +123 -0
- package/src/core/weaver/weaver.ts +118 -209
- package/src/frontend/native/index.ts +17 -2
- package/src/frontend/native/logs.ts +95 -0
- package/src/frontend/native/protocol.ts +39 -0
- package/src/frontend/native/server.ts +25 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.34.1",
|
|
4
4
|
"description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
|
|
5
5
|
"author": "Dylan Neve",
|
|
6
6
|
"license": "MIT",
|
|
@@ -100,8 +100,8 @@
|
|
|
100
100
|
"@grammyjs/transformer-throttler": "^1.2.1",
|
|
101
101
|
"@kilocode/sdk": "^7.2.22",
|
|
102
102
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
103
|
-
"@openai/agents": "^0.
|
|
104
|
-
"@openai/codex-sdk": "^0.
|
|
103
|
+
"@openai/agents": "^0.13.0",
|
|
104
|
+
"@openai/codex-sdk": "^0.143.0",
|
|
105
105
|
"@opencode-ai/sdk": "^1.17.4",
|
|
106
106
|
"@playwright/mcp": "^0.0.77",
|
|
107
107
|
"@types/cross-spawn": "^6.0.6",
|
|
@@ -384,6 +384,19 @@ export async function* runChatTurn(
|
|
|
384
384
|
armPostResultWatchdog();
|
|
385
385
|
}
|
|
386
386
|
}
|
|
387
|
+
|
|
388
|
+
// The SDK doesn't throw on API errors — it converts them into a
|
|
389
|
+
// synthetic assistant message and finishes the turn with an error-
|
|
390
|
+
// flagged result (usage limits, 429s, auth failures all land here).
|
|
391
|
+
// Rethrow the captured error text so this turn takes the SAME path
|
|
392
|
+
// as a thrown SDK error: retry decision, failed-turn accounting, and
|
|
393
|
+
// an `error` event carrying the real message — instead of being
|
|
394
|
+
// treated as a normal reply (which, with no delivery tool call,
|
|
395
|
+
// would trip the flow-violation re-prompt loop and burn more turns
|
|
396
|
+
// against an already-exhausted limit).
|
|
397
|
+
if (state.resultErrorText) {
|
|
398
|
+
throw new Error(state.resultErrorText);
|
|
399
|
+
}
|
|
387
400
|
} catch (err) {
|
|
388
401
|
if (!postResultForceClosed) {
|
|
389
402
|
const buildRetryStream = (
|
|
@@ -75,6 +75,17 @@ export type StreamState = {
|
|
|
75
75
|
* doesn't poison the visible-text delta buffer.
|
|
76
76
|
*/
|
|
77
77
|
unflushedThinkingDelta: string;
|
|
78
|
+
/**
|
|
79
|
+
* Set when the SDK's final result message reported a failed turn —
|
|
80
|
+
* either an error subtype (`error_during_execution`, `error_max_turns`,
|
|
81
|
+
* …) or `is_error: true` on a `success` result. The latter is how API
|
|
82
|
+
* errors surface: the SDK converts them (usage limits, 429s, auth
|
|
83
|
+
* failures) into a synthetic assistant message and finishes the turn
|
|
84
|
+
* "successfully" with the error text in `result` — it never throws.
|
|
85
|
+
* Carries the best error text available so the handler can fail the
|
|
86
|
+
* turn with the REAL message instead of treating it as a normal reply.
|
|
87
|
+
*/
|
|
88
|
+
resultErrorText: string | undefined;
|
|
78
89
|
};
|
|
79
90
|
|
|
80
91
|
export function createStreamState(): StreamState {
|
|
@@ -96,6 +107,7 @@ export function createStreamState(): StreamState {
|
|
|
96
107
|
turnTerminated: false,
|
|
97
108
|
unflushedTextDelta: "",
|
|
98
109
|
unflushedThinkingDelta: "",
|
|
110
|
+
resultErrorText: undefined,
|
|
99
111
|
};
|
|
100
112
|
}
|
|
101
113
|
|
|
@@ -358,6 +370,27 @@ export function processResultMessage(
|
|
|
358
370
|
) {
|
|
359
371
|
state.currentBlockText = msg.result;
|
|
360
372
|
}
|
|
373
|
+
|
|
374
|
+
// Failed turn. Two shapes (see StreamState.resultErrorText):
|
|
375
|
+
// - error subtype: no `result` field, but `errors[]` carries diagnostics.
|
|
376
|
+
// - success subtype with is_error: `result` (and the trailing assistant
|
|
377
|
+
// text) IS the API error message, e.g. "You've hit your weekly limit
|
|
378
|
+
// · resets Jul 10, 9am". Prefer that text — it's the user-facing one.
|
|
379
|
+
if (msg.subtype !== "success") {
|
|
380
|
+
const diagnostics = (msg.errors ?? [])
|
|
381
|
+
.filter((e) => typeof e === "string" && !e.startsWith("[ede_diagnostic]"))
|
|
382
|
+
.join("; ")
|
|
383
|
+
.slice(0, 500);
|
|
384
|
+
state.resultErrorText =
|
|
385
|
+
state.lastTrailingText.trim() ||
|
|
386
|
+
diagnostics ||
|
|
387
|
+
`Claude SDK turn failed (${msg.subtype})`;
|
|
388
|
+
} else if (msg.is_error) {
|
|
389
|
+
state.resultErrorText =
|
|
390
|
+
(typeof msg.result === "string" && msg.result.trim()) ||
|
|
391
|
+
state.lastTrailingText.trim() ||
|
|
392
|
+
"Claude SDK turn failed (API error)";
|
|
393
|
+
}
|
|
361
394
|
}
|
|
362
395
|
|
|
363
396
|
// ── Trailing-text fallback dedup ────────────────────────────────────────────
|
package/src/bootstrap.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
initTriggers,
|
|
29
29
|
resumeAfterRestart as resumeTriggersAfterRestart,
|
|
30
30
|
} from "./core/background/triggers/index.js";
|
|
31
|
-
import { initDream } from "./core/background/dream.js";
|
|
31
|
+
import { initDream, maybeStartDream } from "./core/background/dream.js";
|
|
32
32
|
import { initHeartbeat } from "./core/background/heartbeat/index.js";
|
|
33
33
|
import { log, logWarn, logDebug } from "./util/log.js";
|
|
34
34
|
import type { TalonConfig } from "./util/config.js";
|
|
@@ -409,6 +409,10 @@ export async function initBackendAndDispatcher(
|
|
|
409
409
|
chatId,
|
|
410
410
|
),
|
|
411
411
|
onActivity: () => resetPulseTimer(),
|
|
412
|
+
// Turn-start hook: fire-and-forget dream (background memory
|
|
413
|
+
// consolidation) check. Wired here so the Weaver stays ignorant of
|
|
414
|
+
// the dream subsystem.
|
|
415
|
+
onTurnStart: maybeStartDream,
|
|
412
416
|
});
|
|
413
417
|
|
|
414
418
|
initPulse();
|
|
@@ -259,6 +259,10 @@ export function addUsage(a: UsageSnapshot, b: UsageSnapshot): UsageSnapshot {
|
|
|
259
259
|
*/
|
|
260
260
|
const REASON_TO_KIND: Partial<Record<TalonError["reason"], AgentErrorKind>> = {
|
|
261
261
|
rate_limit: "rate_limit",
|
|
262
|
+
// Subscription usage limits share the rate_limit kind — same family for
|
|
263
|
+
// event consumers; `retryable: false` (carried through) is what
|
|
264
|
+
// distinguishes them from a transient 429.
|
|
265
|
+
usage_limit: "rate_limit",
|
|
262
266
|
overloaded: "overload",
|
|
263
267
|
context_length: "context_overflow",
|
|
264
268
|
session_expired: "session_expired",
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import pRetry, { AbortError } from "p-retry";
|
|
17
17
|
import { classify } from "../errors.js";
|
|
18
18
|
import { getActiveCount } from "./dispatcher.js";
|
|
19
|
-
import { Loom, getActiveLoom } from "../weaver/index.js";
|
|
19
|
+
import { Loom, getActiveLoom, type ContextRegistry } from "../weaver/index.js";
|
|
20
20
|
import { getHealthStatus } from "../../util/watchdog.js";
|
|
21
21
|
import { getActiveSessionCount } from "../../storage/sessions.js";
|
|
22
22
|
import { log, logError, logDebug } from "../../util/log.js";
|
|
@@ -89,13 +89,14 @@ export async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
|
|
|
89
89
|
export class Gateway {
|
|
90
90
|
/**
|
|
91
91
|
* Per-chat live state is owned by the Weaver's Loom. The gateway holds no
|
|
92
|
-
* registry of its own — it delegates here
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
92
|
+
* registry of its own — it delegates here, and only through the
|
|
93
|
+
* `ContextRegistry` face (the gateway never creates or evicts Threads).
|
|
94
|
+
* `getActiveLoom()` returns the Weaver's Loom once the dispatcher is wired;
|
|
95
|
+
* the standalone fallback covers unit tests and the brief startup window
|
|
96
|
+
* before init (when no turn — and so no context — can exist anyway).
|
|
96
97
|
*/
|
|
97
98
|
private readonly ownLoom = new Loom();
|
|
98
|
-
private get loom():
|
|
99
|
+
private get loom(): ContextRegistry {
|
|
99
100
|
return getActiveLoom() ?? this.ownLoom;
|
|
100
101
|
}
|
|
101
102
|
|
package/src/core/errors.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
type ErrorReason =
|
|
11
11
|
| "rate_limit"
|
|
12
|
+
| "usage_limit"
|
|
12
13
|
| "overloaded"
|
|
13
14
|
| "network"
|
|
14
15
|
| "auth"
|
|
@@ -19,6 +20,19 @@ type ErrorReason =
|
|
|
19
20
|
| "telegram_api"
|
|
20
21
|
| "unknown";
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Claude subscription usage-limit messages, as produced by Claude Code /
|
|
25
|
+
* the Agent SDK (see rateLimitMessages.ts upstream: "You've hit your
|
|
26
|
+
* weekly limit · resets Jul 10, 9am", "You're out of extra usage", …)
|
|
27
|
+
* plus the legacy "Claude AI usage limit reached|<ts>" format. These are
|
|
28
|
+
* already user-facing text — classification marks them `usage_limit` so
|
|
29
|
+
* `friendlyMessage` passes them through instead of collapsing them to a
|
|
30
|
+
* generic template. Unlike `rate_limit` (a transient 429), a usage limit
|
|
31
|
+
* doesn't clear on a short retry, so `retryable` stays false.
|
|
32
|
+
*/
|
|
33
|
+
const USAGE_LIMIT_RE =
|
|
34
|
+
/you['’]ve hit your .{0,40}limit|you['’]re out of extra usage|claude ai usage limit reached|usage limit reached/i;
|
|
35
|
+
|
|
22
36
|
// ── TalonError class ────────────────────────────────────────────────────────
|
|
23
37
|
|
|
24
38
|
export class TalonError extends Error {
|
|
@@ -74,6 +88,18 @@ export function classify(err: unknown): TalonError {
|
|
|
74
88
|
const statusMatch = msg.match(/\b([45]\d{2})\b/);
|
|
75
89
|
const status = statusMatch ? parseInt(statusMatch[1], 10) : undefined;
|
|
76
90
|
|
|
91
|
+
// Subscription usage limit — checked before the generic rate-limit
|
|
92
|
+
// branch ("usage limit reached" would otherwise never be reached, and
|
|
93
|
+
// "You've hit your…" carries no 429/rate-limit marker at all).
|
|
94
|
+
if (USAGE_LIMIT_RE.test(msg)) {
|
|
95
|
+
return new TalonError(msg, {
|
|
96
|
+
reason: "usage_limit",
|
|
97
|
+
retryable: false,
|
|
98
|
+
status: status ?? 429,
|
|
99
|
+
cause,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
77
103
|
// Rate limit
|
|
78
104
|
if (/rate.?limit|429|too many requests/i.test(msg)) {
|
|
79
105
|
const retryMatch = msg.match(/retry.?after[:\s]*(\d+)/i);
|
|
@@ -190,6 +216,7 @@ export function classify(err: unknown): TalonError {
|
|
|
190
216
|
|
|
191
217
|
const FRIENDLY_MESSAGES: Record<ErrorReason, string> = {
|
|
192
218
|
rate_limit: "Rate limited. Try again in a moment.",
|
|
219
|
+
usage_limit: "Usage limit reached. Try again after it resets.",
|
|
193
220
|
overloaded:
|
|
194
221
|
"Upstream model is busy right now. Retrying with a faster fallback...",
|
|
195
222
|
network: "Connection issue. Retrying shortly.",
|
|
@@ -203,8 +230,30 @@ const FRIENDLY_MESSAGES: Record<ErrorReason, string> = {
|
|
|
203
230
|
unknown: "Something went wrong. Try again or /reset.",
|
|
204
231
|
};
|
|
205
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Reasons whose template alone tells the user nothing actionable — the
|
|
235
|
+
* underlying error detail is appended so "Something went wrong" always
|
|
236
|
+
* says WHAT went wrong. Templated reasons like `network`/`overloaded`
|
|
237
|
+
* stay terse: they're transient and the detail is just transport noise.
|
|
238
|
+
*/
|
|
239
|
+
const DETAIL_REASONS: ReadonlySet<ErrorReason> = new Set([
|
|
240
|
+
"auth",
|
|
241
|
+
"bad_request",
|
|
242
|
+
"forbidden",
|
|
243
|
+
"unknown",
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
/** Collapse whitespace and clip the raw error for inline display. */
|
|
247
|
+
function clipDetail(msg: string, max = 300): string {
|
|
248
|
+
const flat = msg.replace(/\s+/g, " ").trim();
|
|
249
|
+
if (!flat || flat === "[non-stringifiable error]") return "";
|
|
250
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
251
|
+
}
|
|
252
|
+
|
|
206
253
|
/**
|
|
207
254
|
* Get a user-friendly error message. For rate limits, includes retry timing.
|
|
255
|
+
* Usage-limit and session-expired messages pass through verbatim (they're
|
|
256
|
+
* already user-facing); generic reasons carry the underlying detail.
|
|
208
257
|
*/
|
|
209
258
|
export function friendlyMessage(err: unknown): string {
|
|
210
259
|
const classified = err instanceof TalonError ? err : classify(err);
|
|
@@ -214,10 +263,19 @@ export function friendlyMessage(err: unknown): string {
|
|
|
214
263
|
return `Rate limited. Try again in ${seconds} seconds.`;
|
|
215
264
|
}
|
|
216
265
|
|
|
217
|
-
//
|
|
218
|
-
if (
|
|
219
|
-
|
|
266
|
+
// Already user-friendly from the backend — pass through as-is.
|
|
267
|
+
if (
|
|
268
|
+
classified.reason === "session_expired" ||
|
|
269
|
+
classified.reason === "usage_limit"
|
|
270
|
+
) {
|
|
271
|
+
return classified.message || FRIENDLY_MESSAGES[classified.reason];
|
|
220
272
|
}
|
|
221
273
|
|
|
222
|
-
|
|
274
|
+
const base = FRIENDLY_MESSAGES[classified.reason];
|
|
275
|
+
if (DETAIL_REASONS.has(classified.reason)) {
|
|
276
|
+
const detail = clipDetail(classified.message);
|
|
277
|
+
// Skip when the detail adds nothing over the template itself.
|
|
278
|
+
if (detail && detail !== base) return `${base}\n\nDetail: ${detail}`;
|
|
279
|
+
}
|
|
280
|
+
return base;
|
|
223
281
|
}
|
package/src/core/tools/index.ts
CHANGED
|
@@ -55,6 +55,17 @@ const TURN_TERMINATOR_NAMES: ReadonlySet<string> = new Set(
|
|
|
55
55
|
ALL_TOOLS.filter((t) => t.endsTurn).map((t) => t.name),
|
|
56
56
|
);
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Names of reply-delivery tools (`delivery: true` on the definition):
|
|
60
|
+
* their observable effect is the message/reaction itself, which
|
|
61
|
+
* frontends already surface as first-class output. Kept separate from
|
|
62
|
+
* `TURN_TERMINATOR_NAMES` — `send_message` delivers without ending the
|
|
63
|
+
* turn, and a future terminator need not be a delivery tool.
|
|
64
|
+
*/
|
|
65
|
+
const DELIVERY_TOOL_NAMES: ReadonlySet<string> = new Set(
|
|
66
|
+
ALL_TOOLS.filter((t) => t.delivery).map((t) => t.name),
|
|
67
|
+
);
|
|
68
|
+
|
|
58
69
|
/**
|
|
59
70
|
* All tool names registered in Talon's tool catalog. Used by
|
|
60
71
|
* `stripMcpPrefix` to recognise the bare name when a backend prefixes
|
|
@@ -139,6 +150,24 @@ export function isTurnTerminator(
|
|
|
139
150
|
return true;
|
|
140
151
|
}
|
|
141
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Whether a tool call by this name is reply-delivery plumbing
|
|
155
|
+
* (`delivery: true` on its definition) — `end_turn`, `send`,
|
|
156
|
+
* `send_message`, `react`, … Its effect reaches the user as a chat
|
|
157
|
+
* message or reaction, so activity timelines ("what the model did")
|
|
158
|
+
* must exclude it or the reply gets double-reported as work.
|
|
159
|
+
*
|
|
160
|
+
* Accepts bare names (`end_turn`) and every prefixed form
|
|
161
|
+
* `stripMcpPrefix` understands (`mcp__desktop-tools__end_turn`,
|
|
162
|
+
* Kilo's `desktop-tools_end_turn`).
|
|
163
|
+
*/
|
|
164
|
+
export function isDeliveryTool(toolName: string): boolean {
|
|
165
|
+
return (
|
|
166
|
+
DELIVERY_TOOL_NAMES.has(toolName) ||
|
|
167
|
+
DELIVERY_TOOL_NAMES.has(stripMcpPrefix(toolName))
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
142
171
|
/** Filter options for composing a tool set. */
|
|
143
172
|
export interface ComposeOptions {
|
|
144
173
|
/** Include only tools available on this frontend. */
|
|
@@ -113,6 +113,7 @@ Notes:
|
|
|
113
113
|
frontends: ["telegram", "teams", "discord", "native"],
|
|
114
114
|
tag: "messaging",
|
|
115
115
|
endsTurn: true,
|
|
116
|
+
delivery: true,
|
|
116
117
|
},
|
|
117
118
|
|
|
118
119
|
// ── Telegram unified send ─────────────────────────────────────────────
|
|
@@ -372,6 +373,7 @@ Examples:
|
|
|
372
373
|
},
|
|
373
374
|
frontends: ["telegram", "discord"],
|
|
374
375
|
tag: "messaging",
|
|
376
|
+
delivery: true,
|
|
375
377
|
},
|
|
376
378
|
|
|
377
379
|
// ── Native send_message ────────────────────────────────────────────────
|
|
@@ -388,6 +390,7 @@ Examples:
|
|
|
388
390
|
execute: (params, bridge) => bridge("send_message", params),
|
|
389
391
|
frontends: ["teams", "native"],
|
|
390
392
|
tag: "messaging",
|
|
393
|
+
delivery: true,
|
|
391
394
|
},
|
|
392
395
|
|
|
393
396
|
// ── Native send_message_with_buttons ──────────────────────────────────
|
|
@@ -412,6 +415,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
|
|
|
412
415
|
execute: (params, bridge) => bridge("send_message_with_buttons", params),
|
|
413
416
|
frontends: ["teams", "native"],
|
|
414
417
|
tag: "messaging",
|
|
418
|
+
delivery: true,
|
|
415
419
|
},
|
|
416
420
|
|
|
417
421
|
// ── react ─────────────────────────────────────────────────────────────
|
|
@@ -469,6 +473,7 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
|
|
|
469
473
|
frontends: ["telegram", "discord", "native"],
|
|
470
474
|
tag: "messaging",
|
|
471
475
|
endsTurn: true,
|
|
476
|
+
delivery: true,
|
|
472
477
|
},
|
|
473
478
|
|
|
474
479
|
// ── edit_message ──────────────────────────────────────────────────────
|
package/src/core/tools/types.ts
CHANGED
|
@@ -78,4 +78,15 @@ export interface ToolDefinition {
|
|
|
78
78
|
* is the shared declarative signal, not the implementation.
|
|
79
79
|
*/
|
|
80
80
|
readonly endsTurn?: boolean;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Reply-delivery plumbing: the tool's observable effect IS the
|
|
84
|
+
* message/reaction the user receives, which every frontend already
|
|
85
|
+
* surfaces as first-class output (a chat message, a reaction). Tools
|
|
86
|
+
* flagged here are excluded from activity timelines — the tool
|
|
87
|
+
* events a frontend shows or persists as "what the model did" —
|
|
88
|
+
* because reporting them there double-counts the reply as work.
|
|
89
|
+
* Declared on the definition so consumers never classify by name.
|
|
90
|
+
*/
|
|
91
|
+
readonly delivery?: boolean;
|
|
81
92
|
}
|
package/src/core/weaver/index.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
export { Thread, type Warp, type ThreadSnapshot } from "./thread.js";
|
|
2
2
|
export { ThreadSession, type SessionSummary } from "./thread-session.js";
|
|
3
|
-
export { Loom } from "./loom.js";
|
|
3
|
+
export { Loom, type ContextRegistry } from "./loom.js";
|
|
4
|
+
export { carryTurnEvents, type EventSink } from "./shuttle.js";
|
|
5
|
+
export { startTypingLoop, TYPING_REFRESH_MS } from "./typing-loop.js";
|
|
6
|
+
export { prefetchMemory } from "./memory-prefetch.js";
|
|
7
|
+
export {
|
|
8
|
+
resolveWarp,
|
|
9
|
+
type WarpResolution,
|
|
10
|
+
type WarpResolverDeps,
|
|
11
|
+
} from "./warp-resolver.js";
|
|
4
12
|
export {
|
|
5
13
|
Weaver,
|
|
6
|
-
getWeaver,
|
|
7
14
|
getActiveLoom,
|
|
8
15
|
initWeaver,
|
|
9
16
|
type WeaverDeps,
|
package/src/core/weaver/loom.ts
CHANGED
|
@@ -1,4 +1,30 @@
|
|
|
1
|
-
import { Thread } from "./thread.js";
|
|
1
|
+
import { Thread, type ThreadSnapshot } from "./thread.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The context-tracking face of the Loom — everything the gateway needs
|
|
5
|
+
* to bracket turns and answer numeric-keyed queries, and nothing else.
|
|
6
|
+
* The gateway depends on this interface (not the concrete Loom), so its
|
|
7
|
+
* contract with the registry is explicit and a test can substitute a
|
|
8
|
+
* stub without dragging in Thread creation or eviction.
|
|
9
|
+
*/
|
|
10
|
+
export interface ContextRegistry {
|
|
11
|
+
/** Acquire the execution context for a turn. See `Loom.acquireContext`. */
|
|
12
|
+
acquireContext(numericChatId: number, stringId?: string): Thread;
|
|
13
|
+
/** Release one context hold, addressed by numeric or string id. */
|
|
14
|
+
releaseContext(id: number | string): void;
|
|
15
|
+
/** Count one bridge-sent message against the chat's current turn. */
|
|
16
|
+
noteMessageSent(numericChatId: number): void;
|
|
17
|
+
/** Messages the bridge has sent during the chat's current turn. */
|
|
18
|
+
messageCount(numericChatId: number): number;
|
|
19
|
+
/** Whether a turn is currently holding the chat's execution context. */
|
|
20
|
+
hasActiveContext(numericChatId: number): boolean;
|
|
21
|
+
/** Number of chats currently holding an execution context. */
|
|
22
|
+
activeContextCount(): number;
|
|
23
|
+
/** Resolve a string chat id to the numeric id of its active context. */
|
|
24
|
+
numericForStringId(stringId: string): number | null;
|
|
25
|
+
/** Number of live Threads in the registry. */
|
|
26
|
+
size(): number;
|
|
27
|
+
}
|
|
2
28
|
|
|
3
29
|
/**
|
|
4
30
|
* Loom — the single registry of live chat Threads.
|
|
@@ -9,7 +35,7 @@ import { Thread } from "./thread.js";
|
|
|
9
35
|
* route inbound tool calls and report active chats without owning any
|
|
10
36
|
* per-chat state of its own.
|
|
11
37
|
*/
|
|
12
|
-
export class Loom {
|
|
38
|
+
export class Loom implements ContextRegistry {
|
|
13
39
|
private readonly threads = new Map<string, Thread>();
|
|
14
40
|
private readonly byNumeric = new Map<number, Thread>();
|
|
15
41
|
|
|
@@ -44,6 +70,11 @@ export class Loom {
|
|
|
44
70
|
return [...this.threads.keys()];
|
|
45
71
|
}
|
|
46
72
|
|
|
73
|
+
/** An immutable view of every live Thread. Reading is side-effect free. */
|
|
74
|
+
snapshot(): ThreadSnapshot[] {
|
|
75
|
+
return [...this.threads.values()].map((thread) => thread.describe());
|
|
76
|
+
}
|
|
77
|
+
|
|
47
78
|
// ── Execution context (gateway-facing; absorbed ChatContext) ────────────────
|
|
48
79
|
|
|
49
80
|
/**
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory prefetch (Phase B) — optional pre-retrieval of palace memory
|
|
3
|
+
* for live user messages, strictly fail-closed: a broken palace must
|
|
4
|
+
* never block chat delivery. The result is dynamic turn context passed
|
|
5
|
+
* through ChatRunParams; it never touches the frozen prompt.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { RetrievedMemory } from "../agent-runtime/capabilities.js";
|
|
9
|
+
import type { MemoryRetriever } from "../memory/retrieval.js";
|
|
10
|
+
import { logDebug, logWarn } from "../../util/log.js";
|
|
11
|
+
|
|
12
|
+
export type PrefetchMemoryInput = {
|
|
13
|
+
chatId: string;
|
|
14
|
+
text: string;
|
|
15
|
+
senderName: string;
|
|
16
|
+
isGroup: boolean;
|
|
17
|
+
/** Request id for log correlation. */
|
|
18
|
+
reqId: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export async function prefetchMemory(
|
|
22
|
+
retrieve: MemoryRetriever,
|
|
23
|
+
input: PrefetchMemoryInput,
|
|
24
|
+
): Promise<RetrievedMemory | undefined> {
|
|
25
|
+
try {
|
|
26
|
+
const retrieved = await retrieve({
|
|
27
|
+
runKind: "chat",
|
|
28
|
+
chatId: input.chatId,
|
|
29
|
+
text: input.text,
|
|
30
|
+
senderName: input.senderName,
|
|
31
|
+
isGroup: input.isGroup,
|
|
32
|
+
});
|
|
33
|
+
if (retrieved) {
|
|
34
|
+
logDebug(
|
|
35
|
+
"dispatcher",
|
|
36
|
+
`[${input.reqId}] memory pre-retrieval: ${retrieved.items.length} item(s), ` +
|
|
37
|
+
`${retrieved.items.reduce((n, i) => n + i.text.length, 0)} chars`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return retrieved;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
logWarn(
|
|
43
|
+
"dispatcher",
|
|
44
|
+
`[${input.reqId}] memory pre-retrieval failed (running turn without it): ` +
|
|
45
|
+
(err instanceof Error ? err.message : String(err)),
|
|
46
|
+
);
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shuttle — carries the weft across the warp: consumes a backend's
|
|
3
|
+
* canonical `AgentEvent` stream and forwards every event to the
|
|
4
|
+
* frontend's `onEvent` sink (no callback bridge, no back-translation).
|
|
5
|
+
*
|
|
6
|
+
* The Shuttle owns the three stream-consumption invariants so no caller
|
|
7
|
+
* can get them subtly wrong:
|
|
8
|
+
*
|
|
9
|
+
* - **ordering** — events are awaited in stream order, so a consumer
|
|
10
|
+
* that needs serial delivery gets it;
|
|
11
|
+
* - **ack settlement** — `assistant_message.deliveryAck` is ALWAYS
|
|
12
|
+
* settled, even when no `onEvent` sink is supplied or the sink
|
|
13
|
+
* ignores the event. Otherwise the callback-shaped backend
|
|
14
|
+
* (handler-to-events) blocks forever awaiting delivery
|
|
15
|
+
* confirmation. The frontend's job is just to deliver and throw on
|
|
16
|
+
* failure; the Shuttle maps that onto the ack (resolve on success →
|
|
17
|
+
* block delivered; reject on throw → backend retries, e.g. Codex
|
|
18
|
+
* oversized-message path);
|
|
19
|
+
* - **terminators** — the `completed` event's `AgentResult` is
|
|
20
|
+
* captured for the return value, and an `error` terminator is
|
|
21
|
+
* rethrown as `AgentRunError` so callers' catch paths keep working.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
AgentRunError,
|
|
26
|
+
type AgentEvent,
|
|
27
|
+
type AgentResult,
|
|
28
|
+
} from "../agent-runtime/events.js";
|
|
29
|
+
|
|
30
|
+
export type EventSink = (event: AgentEvent) => void | Promise<void>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Pump the stream to completion. Returns the `completed` event's
|
|
34
|
+
* result (if the backend emitted one); throws `AgentRunError` when the
|
|
35
|
+
* stream terminates with an `error` event.
|
|
36
|
+
*/
|
|
37
|
+
export async function carryTurnEvents(
|
|
38
|
+
stream: AsyncIterable<AgentEvent>,
|
|
39
|
+
onEvent?: EventSink,
|
|
40
|
+
): Promise<AgentResult | undefined> {
|
|
41
|
+
let agentResult: AgentResult | undefined;
|
|
42
|
+
for await (const event of stream) {
|
|
43
|
+
if (event.type === "completed") {
|
|
44
|
+
agentResult = event.result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (event.type === "assistant_message" && event.deliveryAck) {
|
|
48
|
+
try {
|
|
49
|
+
await onEvent?.(event);
|
|
50
|
+
event.deliveryAck.resolve();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
event.deliveryAck.reject(err);
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await onEvent?.(event);
|
|
58
|
+
if (event.type === "error") {
|
|
59
|
+
throw new AgentRunError(event.error);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return agentResult;
|
|
63
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typing loop — keeps the frontend's typing indicator alive for the
|
|
3
|
+
* duration of a turn. Sends immediately, then re-sends on an interval;
|
|
4
|
+
* every send is fail-soft (logged, never thrown) because a dropped
|
|
5
|
+
* indicator must not fail the turn.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { logWarn } from "../../util/log.js";
|
|
9
|
+
|
|
10
|
+
/** Platforms expire typing indicators after ~5s; refresh under that. */
|
|
11
|
+
export const TYPING_REFRESH_MS = 4000;
|
|
12
|
+
|
|
13
|
+
export type SendTyping = (
|
|
14
|
+
numericChatId: number,
|
|
15
|
+
stringId?: string,
|
|
16
|
+
) => Promise<void>;
|
|
17
|
+
|
|
18
|
+
/** Start the loop. Call the returned function to stop it. */
|
|
19
|
+
export function startTypingLoop(
|
|
20
|
+
sendTyping: SendTyping,
|
|
21
|
+
numericChatId: number,
|
|
22
|
+
stringId: string,
|
|
23
|
+
intervalMs = TYPING_REFRESH_MS,
|
|
24
|
+
): () => void {
|
|
25
|
+
const send = (label: string) => {
|
|
26
|
+
sendTyping(numericChatId, stringId).catch((err: unknown) => {
|
|
27
|
+
logWarn(
|
|
28
|
+
"dispatcher",
|
|
29
|
+
`${label} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
send("sendTyping");
|
|
34
|
+
const timer = setInterval(() => send("sendTyping interval"), intervalMs);
|
|
35
|
+
return () => clearInterval(timer);
|
|
36
|
+
}
|