talon-agent 1.34.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/bootstrap.ts +5 -1
- package/src/core/engine/gateway.ts +7 -6
- 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 +13 -1
- package/src/frontend/native/protocol.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "1.34.
|
|
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",
|
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();
|
|
@@ -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/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
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WarpResolver — turns an incoming turn into the model/backend binding
|
|
3
|
+
* (the warp) it will run on.
|
|
4
|
+
*
|
|
5
|
+
* Owns the whole resolution ladder so the Weaver never sees a partial
|
|
6
|
+
* state: active-model resolution, the send-time null-model guard, and
|
|
7
|
+
* the per-run override (triggers/cron) with its fall-back-to-chat-model
|
|
8
|
+
* safety. The result is a discriminated union — either a bound warp or
|
|
9
|
+
* a refusal with the user-facing message to deliver — so callers can't
|
|
10
|
+
* forget to handle the "no model" case.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ModelRef } from "../agent-runtime/model-ref.js";
|
|
14
|
+
import { logDebug, logWarn } from "../../util/log.js";
|
|
15
|
+
|
|
16
|
+
export type WarpResolverDeps = {
|
|
17
|
+
/**
|
|
18
|
+
* Walks the 5-step active-model resolution chain for the chat. When
|
|
19
|
+
* `model`/`ref` are both `null` (catalog-driven backend with no
|
|
20
|
+
* per-chat pick and no operator default) the turn is refused — see
|
|
21
|
+
* `resolve()`.
|
|
22
|
+
*/
|
|
23
|
+
resolveActiveModel: (chatId: string) => Promise<{
|
|
24
|
+
model: string | null;
|
|
25
|
+
ref: ModelRef | null;
|
|
26
|
+
backendId: string;
|
|
27
|
+
}>;
|
|
28
|
+
/**
|
|
29
|
+
* Validates + materialises an explicit per-run model id against the
|
|
30
|
+
* chat's backend. Returns `null` when the id isn't selectable, so
|
|
31
|
+
* the resolver falls back to the chat model. Restricted to the
|
|
32
|
+
* chat's own backend so the session still resumes.
|
|
33
|
+
*/
|
|
34
|
+
resolveModelOverride?: (
|
|
35
|
+
chatId: string,
|
|
36
|
+
modelId: string,
|
|
37
|
+
) => Promise<ModelRef | null>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ResolveWarpInput = {
|
|
41
|
+
chatId: string;
|
|
42
|
+
/** Optional per-run model override (triggers/cron). */
|
|
43
|
+
modelOverride?: string;
|
|
44
|
+
/** Turn source, used only for override logging. */
|
|
45
|
+
source: string;
|
|
46
|
+
/** Request id for log correlation. */
|
|
47
|
+
reqId: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type WarpResolution =
|
|
51
|
+
| {
|
|
52
|
+
ok: true;
|
|
53
|
+
/** The ModelRef the turn runs on (post-override). */
|
|
54
|
+
ref: ModelRef;
|
|
55
|
+
backendId: string;
|
|
56
|
+
/** A per-run override was applied for this turn. */
|
|
57
|
+
overridden: boolean;
|
|
58
|
+
}
|
|
59
|
+
| {
|
|
60
|
+
ok: false;
|
|
61
|
+
backendId: string;
|
|
62
|
+
/** User-facing refusal ("use /model to pick one"). */
|
|
63
|
+
message: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export async function resolveWarp(
|
|
67
|
+
deps: WarpResolverDeps,
|
|
68
|
+
input: ResolveWarpInput,
|
|
69
|
+
): Promise<WarpResolution> {
|
|
70
|
+
const { chatId, modelOverride, source, reqId } = input;
|
|
71
|
+
|
|
72
|
+
// Send-time null-model guard. When the active-model resolver returns
|
|
73
|
+
// no usable model, refuse to call the backend — it would either error
|
|
74
|
+
// opaquely or run on the wrong default.
|
|
75
|
+
const { model, ref, backendId } = await deps.resolveActiveModel(chatId);
|
|
76
|
+
if (model === null || ref === null) {
|
|
77
|
+
logWarn(
|
|
78
|
+
"dispatcher",
|
|
79
|
+
`[${reqId}] refusing query: no model resolved (chat=${chatId}, backend=${backendId})`,
|
|
80
|
+
);
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
backendId,
|
|
84
|
+
message:
|
|
85
|
+
`No model selected for backend \`${backendId}\`. ` +
|
|
86
|
+
`Use /model to pick one — or set ` +
|
|
87
|
+
`\`backendDefaults.${backendId}\` in talon.json to apply a ` +
|
|
88
|
+
`default for all chats on this backend.`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Per-run model override (triggers/cron). Resolve against the chat's
|
|
93
|
+
// backend; on success swap the ref for this turn only, on failure fall
|
|
94
|
+
// back to the chat model so a stale override never kills the run.
|
|
95
|
+
let runRef = ref;
|
|
96
|
+
if (modelOverride && deps.resolveModelOverride) {
|
|
97
|
+
try {
|
|
98
|
+
const overrideRef = await deps.resolveModelOverride(
|
|
99
|
+
chatId,
|
|
100
|
+
modelOverride,
|
|
101
|
+
);
|
|
102
|
+
if (overrideRef) {
|
|
103
|
+
runRef = overrideRef;
|
|
104
|
+
logDebug(
|
|
105
|
+
"dispatcher",
|
|
106
|
+
`[${reqId}] model override → ${modelOverride} (${source})`,
|
|
107
|
+
);
|
|
108
|
+
} else {
|
|
109
|
+
logWarn(
|
|
110
|
+
"dispatcher",
|
|
111
|
+
`[${reqId}] model override "${modelOverride}" not resolvable on backend ${backendId}; using chat model`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
logWarn(
|
|
116
|
+
"dispatcher",
|
|
117
|
+
`[${reqId}] model override resolution threw: ${err instanceof Error ? err.message : String(err)}; using chat model`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { ok: true, ref: runRef, backendId, overridden: runRef !== ref };
|
|
123
|
+
}
|
|
@@ -1,18 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weaver — the turn orchestrator. `runTurn` serializes each turn onto
|
|
3
|
+
* its chat's Thread (per-chat FIFO, cross-chat parallel) and drives the
|
|
4
|
+
* turn lifecycle by composing the weaver's single-purpose collaborators:
|
|
5
|
+
*
|
|
6
|
+
* - `resolveWarp` (warp-resolver.ts) — model/backend binding + the
|
|
7
|
+
* null-model guard and per-run override fallback;
|
|
8
|
+
* - `startTypingLoop` (typing-loop.ts) — keeps the frontend's typing
|
|
9
|
+
* indicator alive for the duration of the turn;
|
|
10
|
+
* - `prefetchMemory` (memory-prefetch.ts) — optional fail-closed
|
|
11
|
+
* palace pre-retrieval for live user messages;
|
|
12
|
+
* - `carryTurnEvents` (shuttle.ts) — pumps the backend's AgentEvent
|
|
13
|
+
* stream into the frontend sink, settles delivery acks, captures
|
|
14
|
+
* the result and rethrows error terminators.
|
|
15
|
+
*
|
|
16
|
+
* The Weaver itself only sequences those stages and brackets them with
|
|
17
|
+
* the Thread's execution context — it holds no per-chat state (that's
|
|
18
|
+
* the Thread's) and no policy of its own beyond ordering.
|
|
19
|
+
*/
|
|
20
|
+
|
|
1
21
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import type {
|
|
3
|
-
|
|
4
|
-
RetrievedMemory,
|
|
5
|
-
} from "../agent-runtime/capabilities.js";
|
|
6
|
-
import { AgentRunError, type AgentResult } from "../agent-runtime/events.js";
|
|
22
|
+
import type { Backend } from "../agent-runtime/capabilities.js";
|
|
23
|
+
import type { AgentResult } from "../agent-runtime/events.js";
|
|
7
24
|
import type { ModelRef } from "../agent-runtime/model-ref.js";
|
|
8
|
-
import { maybeStartDream } from "../background/dream.js";
|
|
9
25
|
import type { MemoryRetriever } from "../memory/retrieval.js";
|
|
10
26
|
import type { ContextManager, ExecuteParams, ExecuteResult } from "../types.js";
|
|
11
27
|
import { log, logDebug, logWarn } from "../../util/log.js";
|
|
12
28
|
import { Loom } from "./loom.js";
|
|
29
|
+
import { prefetchMemory } from "./memory-prefetch.js";
|
|
30
|
+
import { carryTurnEvents } from "./shuttle.js";
|
|
13
31
|
import type { Thread, ThreadSnapshot } from "./thread.js";
|
|
32
|
+
import { startTypingLoop } from "./typing-loop.js";
|
|
33
|
+
import { resolveWarp } from "./warp-resolver.js";
|
|
14
34
|
|
|
15
35
|
export type WeaverDeps = {
|
|
36
|
+
/**
|
|
37
|
+
* Read fresh per call so backend swaps (chat-role rebinds or
|
|
38
|
+
* per-chat overrides via the controller) take effect on the next
|
|
39
|
+
* query without a dispatcher re-init.
|
|
40
|
+
*/
|
|
16
41
|
getBackend: (chatId?: string) => Backend;
|
|
17
42
|
resolveActiveModel: (chatId: string) => Promise<{
|
|
18
43
|
model: string | null;
|
|
@@ -26,6 +51,14 @@ export type WeaverDeps = {
|
|
|
26
51
|
context: ContextManager;
|
|
27
52
|
sendTyping: (chatId: number, stringId?: string) => Promise<void>;
|
|
28
53
|
onActivity: () => void;
|
|
54
|
+
/**
|
|
55
|
+
* Optional fire-and-forget hook invoked at the start of every turn,
|
|
56
|
+
* after the warp is bound and before the backend is called. The
|
|
57
|
+
* composition root wires background work here (e.g. dream memory
|
|
58
|
+
* consolidation) so the Weaver stays ignorant of those subsystems.
|
|
59
|
+
* Must not throw and must not block — it is called synchronously.
|
|
60
|
+
*/
|
|
61
|
+
onTurnStart?: () => void;
|
|
29
62
|
/**
|
|
30
63
|
* Optional memory pre-retrieval (Phase B). Called for `source: "message"`
|
|
31
64
|
* turns after model/backend resolution and context acquisition, before
|
|
@@ -38,6 +71,7 @@ export type WeaverDeps = {
|
|
|
38
71
|
export class Weaver {
|
|
39
72
|
readonly loom: Loom;
|
|
40
73
|
private readonly deps: WeaverDeps;
|
|
74
|
+
private activeCount = 0;
|
|
41
75
|
|
|
42
76
|
constructor(deps: WeaverDeps, loom = new Loom()) {
|
|
43
77
|
this.deps = deps;
|
|
@@ -49,6 +83,7 @@ export class Weaver {
|
|
|
49
83
|
return thread.enqueue(() => this.run(thread, params));
|
|
50
84
|
}
|
|
51
85
|
|
|
86
|
+
/** Number of turns currently running (not queued) across all chats. */
|
|
52
87
|
getActiveCount(): number {
|
|
53
88
|
return this.activeCount;
|
|
54
89
|
}
|
|
@@ -59,14 +94,9 @@ export class Weaver {
|
|
|
59
94
|
* frontends. Reading is side-effect free.
|
|
60
95
|
*/
|
|
61
96
|
snapshot(): ThreadSnapshot[] {
|
|
62
|
-
return this.loom
|
|
63
|
-
.chatIds()
|
|
64
|
-
.map((id) => this.loom.get(id)?.describe())
|
|
65
|
-
.filter((s): s is ThreadSnapshot => s !== undefined);
|
|
97
|
+
return this.loom.snapshot();
|
|
66
98
|
}
|
|
67
99
|
|
|
68
|
-
private activeCount = 0;
|
|
69
|
-
|
|
70
100
|
private async run(
|
|
71
101
|
thread: Thread,
|
|
72
102
|
params: ExecuteParams,
|
|
@@ -83,95 +113,32 @@ export class Weaver {
|
|
|
83
113
|
thread: Thread,
|
|
84
114
|
params: ExecuteParams,
|
|
85
115
|
): Promise<ExecuteResult> {
|
|
86
|
-
const {
|
|
87
|
-
|
|
88
|
-
resolveActiveModel,
|
|
89
|
-
resolveModelOverride,
|
|
90
|
-
context,
|
|
91
|
-
sendTyping,
|
|
92
|
-
onActivity,
|
|
93
|
-
retrieveMemory,
|
|
94
|
-
} = this.deps;
|
|
95
|
-
// Read the backend fresh per call so backend swaps (chat-role
|
|
96
|
-
// rebinds or per-chat overrides via the controller) take effect on
|
|
97
|
-
// the next query without a dispatcher re-init.
|
|
98
|
-
const backend = getBackend(params.chatId);
|
|
116
|
+
const { context, onActivity, retrieveMemory } = this.deps;
|
|
117
|
+
const backend = this.deps.getBackend(params.chatId);
|
|
99
118
|
const reqId = randomBytes(4).toString("hex");
|
|
100
119
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
backendId,
|
|
112
|
-
} = await resolveActiveModel(params.chatId);
|
|
113
|
-
if (resolvedModel === null || resolvedRef === null) {
|
|
114
|
-
const message =
|
|
115
|
-
`No model selected for backend \`${backendId}\`. ` +
|
|
116
|
-
`Use /model to pick one — or set ` +
|
|
117
|
-
`\`backendDefaults.${backendId}\` in talon.json to apply a ` +
|
|
118
|
-
`default for all chats on this backend.`;
|
|
119
|
-
logWarn(
|
|
120
|
-
"dispatcher",
|
|
121
|
-
`[${reqId}] refusing query: no model resolved (chat=${params.chatId}, backend=${backendId})`,
|
|
122
|
-
);
|
|
120
|
+
const warp = await resolveWarp(this.deps, {
|
|
121
|
+
chatId: params.chatId,
|
|
122
|
+
modelOverride: params.modelOverride,
|
|
123
|
+
source: params.source,
|
|
124
|
+
reqId,
|
|
125
|
+
});
|
|
126
|
+
if (!warp.ok) {
|
|
127
|
+
// Refusals are delivered through the same event sink the backend
|
|
128
|
+
// would use for output (as an `assistant_message` event, so the
|
|
129
|
+
// frontend delivers it normally).
|
|
123
130
|
try {
|
|
124
|
-
await params.onEvent?.({
|
|
131
|
+
await params.onEvent?.({
|
|
132
|
+
type: "assistant_message",
|
|
133
|
+
text: warp.message,
|
|
134
|
+
});
|
|
125
135
|
} catch (err) {
|
|
126
136
|
logWarn(
|
|
127
137
|
"dispatcher",
|
|
128
138
|
`onEvent(no-model) threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
129
139
|
);
|
|
130
140
|
}
|
|
131
|
-
return
|
|
132
|
-
text: message,
|
|
133
|
-
durationMs: 0,
|
|
134
|
-
inputTokens: 0,
|
|
135
|
-
outputTokens: 0,
|
|
136
|
-
cacheRead: 0,
|
|
137
|
-
cacheWrite: 0,
|
|
138
|
-
bridgeMessageCount: context.getMessageCount(
|
|
139
|
-
params.numericChatId,
|
|
140
|
-
params.chatId,
|
|
141
|
-
),
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Per-run model override (triggers/cron). Resolve against the chat's
|
|
146
|
-
// backend; on success swap the ref for this turn only, on failure fall
|
|
147
|
-
// back to the chat model so a stale override never kills the run. The
|
|
148
|
-
// override is restricted to the chat's own backend, so the session still
|
|
149
|
-
// resumes — only the model changes.
|
|
150
|
-
let runRef = resolvedRef;
|
|
151
|
-
if (params.modelOverride && resolveModelOverride) {
|
|
152
|
-
try {
|
|
153
|
-
const overrideRef = await resolveModelOverride(
|
|
154
|
-
params.chatId,
|
|
155
|
-
params.modelOverride,
|
|
156
|
-
);
|
|
157
|
-
if (overrideRef) {
|
|
158
|
-
runRef = overrideRef;
|
|
159
|
-
logDebug(
|
|
160
|
-
"dispatcher",
|
|
161
|
-
`[${reqId}] model override → ${params.modelOverride} (${params.source})`,
|
|
162
|
-
);
|
|
163
|
-
} else {
|
|
164
|
-
logWarn(
|
|
165
|
-
"dispatcher",
|
|
166
|
-
`[${reqId}] model override "${params.modelOverride}" not resolvable on backend ${backendId}; using chat model`,
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
} catch (err) {
|
|
170
|
-
logWarn(
|
|
171
|
-
"dispatcher",
|
|
172
|
-
`[${reqId}] model override resolution threw: ${err instanceof Error ? err.message : String(err)}; using chat model`,
|
|
173
|
-
);
|
|
174
|
-
}
|
|
141
|
+
return this.emptyResult(warp.message, params);
|
|
175
142
|
}
|
|
176
143
|
|
|
177
144
|
// Bind the warp — record the model/backend actually resolved for this turn
|
|
@@ -179,158 +146,105 @@ export class Weaver {
|
|
|
179
146
|
// last turn (per-chat rebind, per-run override, or config drift) is logged
|
|
180
147
|
// rather than passing silently.
|
|
181
148
|
const { drifted, previous } = thread.bindWarp({
|
|
182
|
-
model:
|
|
183
|
-
backendId,
|
|
184
|
-
overridden:
|
|
149
|
+
model: warp.ref.id,
|
|
150
|
+
backendId: warp.backendId,
|
|
151
|
+
overridden: warp.overridden,
|
|
185
152
|
boundAt: Date.now(),
|
|
186
153
|
});
|
|
187
154
|
if (drifted && previous) {
|
|
188
155
|
logDebug(
|
|
189
156
|
"dispatcher",
|
|
190
|
-
`[${reqId}] warp drift chat=${params.chatId}: ${previous.backendId}/${previous.model} → ${backendId}/${
|
|
157
|
+
`[${reqId}] warp drift chat=${params.chatId}: ${previous.backendId}/${previous.model} → ${warp.backendId}/${warp.ref.id}`,
|
|
191
158
|
);
|
|
192
159
|
}
|
|
193
160
|
|
|
194
|
-
|
|
195
|
-
maybeStartDream();
|
|
161
|
+
this.deps.onTurnStart?.();
|
|
196
162
|
|
|
197
163
|
logDebug(
|
|
198
164
|
"dispatcher",
|
|
199
165
|
`[${reqId}] ${params.source} chat=${params.chatId} started (active=${this.activeCount})`,
|
|
200
166
|
);
|
|
201
167
|
context.acquire(params.numericChatId, params.chatId);
|
|
202
|
-
|
|
203
|
-
|
|
168
|
+
const stopTyping = startTypingLoop(
|
|
169
|
+
this.deps.sendTyping,
|
|
170
|
+
params.numericChatId,
|
|
171
|
+
params.chatId,
|
|
172
|
+
);
|
|
204
173
|
try {
|
|
205
|
-
await sendTyping(params.numericChatId, params.chatId).catch(
|
|
206
|
-
(err: unknown) => {
|
|
207
|
-
logWarn(
|
|
208
|
-
"dispatcher",
|
|
209
|
-
`sendTyping failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
210
|
-
);
|
|
211
|
-
},
|
|
212
|
-
);
|
|
213
|
-
typingTimer = setInterval(() => {
|
|
214
|
-
sendTyping(params.numericChatId, params.chatId).catch(
|
|
215
|
-
(err: unknown) => {
|
|
216
|
-
logWarn(
|
|
217
|
-
"dispatcher",
|
|
218
|
-
`sendTyping interval failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
219
|
-
);
|
|
220
|
-
},
|
|
221
|
-
);
|
|
222
|
-
}, 4000);
|
|
223
|
-
|
|
224
|
-
// Consume the backend's native `AgentEvent` stream and forward
|
|
225
|
-
// every event straight to the frontend's `onEvent` sink (no
|
|
226
|
-
// callback bridge). Capture the `completed` event's `AgentResult`
|
|
227
|
-
// for the dispatcher's return value, and rethrow an `error`
|
|
228
|
-
// terminator as `AgentRunError` so callers' catch paths keep
|
|
229
|
-
// working. Events are awaited in stream order so a consumer that
|
|
230
|
-
// needs serial delivery gets it.
|
|
231
174
|
if (!backend.chat) {
|
|
232
175
|
throw new Error(
|
|
233
176
|
`Backend "${backend.id}" has no chat capability — cannot run a turn.`,
|
|
234
177
|
);
|
|
235
178
|
}
|
|
236
179
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
text: params.prompt,
|
|
248
|
-
senderName: params.senderName,
|
|
249
|
-
isGroup: params.isGroup,
|
|
250
|
-
});
|
|
251
|
-
if (retrievedMemory) {
|
|
252
|
-
logDebug(
|
|
253
|
-
"dispatcher",
|
|
254
|
-
`[${reqId}] memory pre-retrieval: ${retrievedMemory.items.length} item(s), ` +
|
|
255
|
-
`${retrievedMemory.items.reduce((n, i) => n + i.text.length, 0)} chars`,
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
} catch (err) {
|
|
259
|
-
logWarn(
|
|
260
|
-
"dispatcher",
|
|
261
|
-
`[${reqId}] memory pre-retrieval failed (running turn without it): ` +
|
|
262
|
-
(err instanceof Error ? err.message : String(err)),
|
|
263
|
-
);
|
|
264
|
-
retrievedMemory = undefined;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
180
|
+
const retrievedMemory =
|
|
181
|
+
retrieveMemory && params.source === "message"
|
|
182
|
+
? await prefetchMemory(retrieveMemory, {
|
|
183
|
+
chatId: params.chatId,
|
|
184
|
+
text: params.prompt,
|
|
185
|
+
senderName: params.senderName,
|
|
186
|
+
isGroup: params.isGroup,
|
|
187
|
+
reqId,
|
|
188
|
+
})
|
|
189
|
+
: undefined;
|
|
267
190
|
|
|
268
191
|
const stream = backend.chat.runChatTurn({
|
|
269
192
|
chatId: params.chatId,
|
|
270
|
-
model:
|
|
193
|
+
model: warp.ref,
|
|
271
194
|
text: params.prompt,
|
|
272
195
|
senderName: params.senderName,
|
|
273
196
|
isGroup: params.isGroup,
|
|
274
197
|
messageId: params.messageId,
|
|
275
198
|
retrievedMemory,
|
|
276
199
|
});
|
|
277
|
-
|
|
278
|
-
for await (const event of stream) {
|
|
279
|
-
if (event.type === "completed") {
|
|
280
|
-
agentResult = event.result;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// The dispatcher owns `assistant_message.deliveryAck` settlement
|
|
284
|
-
// so it is ALWAYS resolved — even when no `onEvent` sink is
|
|
285
|
-
// supplied, or the sink ignores the event. Otherwise the
|
|
286
|
-
// callback-shaped backend (handler-to-events) blocks forever
|
|
287
|
-
// awaiting delivery confirmation. The frontend's job is just to
|
|
288
|
-
// deliver and throw on failure; the dispatcher maps that onto the
|
|
289
|
-
// ack (resolve on success → block delivered; reject on throw →
|
|
290
|
-
// backend retries, e.g. Codex oversized-message path).
|
|
291
|
-
if (event.type === "assistant_message" && event.deliveryAck) {
|
|
292
|
-
try {
|
|
293
|
-
await params.onEvent?.(event);
|
|
294
|
-
event.deliveryAck.resolve();
|
|
295
|
-
} catch (err) {
|
|
296
|
-
event.deliveryAck.reject(err);
|
|
297
|
-
}
|
|
298
|
-
continue;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
await params.onEvent?.(event);
|
|
302
|
-
if (event.type === "error") {
|
|
303
|
-
throw new AgentRunError(event.error);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
const result = {
|
|
307
|
-
text: agentResult?.text ?? "",
|
|
308
|
-
durationMs: agentResult?.durationMs ?? 0,
|
|
309
|
-
inputTokens: agentResult?.usage.inputTokens ?? 0,
|
|
310
|
-
outputTokens: agentResult?.usage.outputTokens ?? 0,
|
|
311
|
-
cacheRead: agentResult?.usage.cacheRead ?? 0,
|
|
312
|
-
cacheWrite: agentResult?.usage.cacheWrite ?? 0,
|
|
313
|
-
};
|
|
200
|
+
const agentResult = await carryTurnEvents(stream, params.onEvent);
|
|
314
201
|
|
|
315
202
|
onActivity();
|
|
316
|
-
|
|
317
203
|
logDebug(
|
|
318
204
|
"dispatcher",
|
|
319
|
-
`[${reqId}] completed in ${
|
|
205
|
+
`[${reqId}] completed in ${agentResult?.durationMs ?? 0}ms ` +
|
|
206
|
+
`(in=${agentResult?.usage.inputTokens ?? 0} out=${agentResult?.usage.outputTokens ?? 0})`,
|
|
320
207
|
);
|
|
321
208
|
|
|
322
|
-
return
|
|
323
|
-
...result,
|
|
324
|
-
bridgeMessageCount: context.getMessageCount(
|
|
325
|
-
params.numericChatId,
|
|
326
|
-
params.chatId,
|
|
327
|
-
),
|
|
328
|
-
};
|
|
209
|
+
return this.toExecuteResult(agentResult, params);
|
|
329
210
|
} finally {
|
|
330
|
-
|
|
211
|
+
stopTyping();
|
|
331
212
|
context.release(params.numericChatId, params.chatId);
|
|
332
213
|
}
|
|
333
214
|
}
|
|
215
|
+
|
|
216
|
+
private toExecuteResult(
|
|
217
|
+
result: AgentResult | undefined,
|
|
218
|
+
params: ExecuteParams,
|
|
219
|
+
): ExecuteResult {
|
|
220
|
+
return {
|
|
221
|
+
text: result?.text ?? "",
|
|
222
|
+
durationMs: result?.durationMs ?? 0,
|
|
223
|
+
inputTokens: result?.usage.inputTokens ?? 0,
|
|
224
|
+
outputTokens: result?.usage.outputTokens ?? 0,
|
|
225
|
+
cacheRead: result?.usage.cacheRead ?? 0,
|
|
226
|
+
cacheWrite: result?.usage.cacheWrite ?? 0,
|
|
227
|
+
bridgeMessageCount: this.deps.context.getMessageCount(
|
|
228
|
+
params.numericChatId,
|
|
229
|
+
params.chatId,
|
|
230
|
+
),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private emptyResult(text: string, params: ExecuteParams): ExecuteResult {
|
|
235
|
+
return {
|
|
236
|
+
text,
|
|
237
|
+
durationMs: 0,
|
|
238
|
+
inputTokens: 0,
|
|
239
|
+
outputTokens: 0,
|
|
240
|
+
cacheRead: 0,
|
|
241
|
+
cacheWrite: 0,
|
|
242
|
+
bridgeMessageCount: this.deps.context.getMessageCount(
|
|
243
|
+
params.numericChatId,
|
|
244
|
+
params.chatId,
|
|
245
|
+
),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
334
248
|
}
|
|
335
249
|
|
|
336
250
|
let weaver: Weaver | null = null;
|
|
@@ -341,11 +255,6 @@ export function initWeaver(deps: WeaverDeps): Weaver {
|
|
|
341
255
|
return weaver;
|
|
342
256
|
}
|
|
343
257
|
|
|
344
|
-
export function getWeaver(): Weaver {
|
|
345
|
-
if (!weaver) throw new Error("Weaver not initialized");
|
|
346
|
-
return weaver;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
258
|
/**
|
|
350
259
|
* The active Weaver's Loom, or `null` before the dispatcher is wired. The
|
|
351
260
|
* gateway delegates its per-chat context bookkeeping here so the Loom is the
|
|
@@ -30,6 +30,7 @@ import { resolveActiveModelForChat } from "../../core/models/active-model.js";
|
|
|
30
30
|
import { forceDream } from "../../core/background/dream.js";
|
|
31
31
|
import { execute } from "../../core/engine/dispatcher.js";
|
|
32
32
|
import { toolInputToRecord } from "../../core/agent-runtime/events.js";
|
|
33
|
+
import { isDeliveryTool } from "../../core/tools/index.js";
|
|
33
34
|
import {
|
|
34
35
|
pushMessage,
|
|
35
36
|
getRecentHistory,
|
|
@@ -576,6 +577,12 @@ export function createNativeFrontend(
|
|
|
576
577
|
broadcast({ kind: "delta", chatId: entry.id, text: event.text });
|
|
577
578
|
break;
|
|
578
579
|
case "tool_call": {
|
|
580
|
+
// Delivery plumbing (end_turn / send_message / react) never
|
|
581
|
+
// enters the tool timeline — its effect arrives as the
|
|
582
|
+
// `message`/reaction itself. Skipping here keeps the live
|
|
583
|
+
// stream, the mid-turn replay to late joiners, and the
|
|
584
|
+
// persisted turn meta consistent from one choke point.
|
|
585
|
+
if (isDeliveryTool(event.name)) break;
|
|
579
586
|
const input = toolInputToRecord(event.name, event.input);
|
|
580
587
|
turnTools.set(event.id, {
|
|
581
588
|
call: { id: event.id, name: event.name, input },
|
|
@@ -592,6 +599,7 @@ export function createNativeFrontend(
|
|
|
592
599
|
break;
|
|
593
600
|
}
|
|
594
601
|
case "tool_result": {
|
|
602
|
+
if (isDeliveryTool(event.name)) break;
|
|
595
603
|
const output = summarizeToolResult(event.result);
|
|
596
604
|
const live = turnTools.get(event.id);
|
|
597
605
|
if (live) {
|
|
@@ -1027,7 +1035,11 @@ export function createNativeFrontend(
|
|
|
1027
1035
|
if (msg.role === "assistant") {
|
|
1028
1036
|
const meta = getTurnMeta(id, msg.id);
|
|
1029
1037
|
if (meta) {
|
|
1030
|
-
|
|
1038
|
+
// Delivery tools are excluded at record time, but metas
|
|
1039
|
+
// persisted by older daemons still carry them — filter on
|
|
1040
|
+
// the way out so upgraded installs get clean history too.
|
|
1041
|
+
const tools = meta.tools?.filter((t) => !isDeliveryTool(t.name));
|
|
1042
|
+
if (tools?.length) msg.tools = tools;
|
|
1031
1043
|
if (meta.durationMs) msg.durationMs = meta.durationMs;
|
|
1032
1044
|
if (meta.tokensIn) msg.tokensIn = meta.tokensIn;
|
|
1033
1045
|
if (meta.tokensOut) msg.tokensOut = meta.tokensOut;
|
|
@@ -35,6 +35,11 @@ export type ClientButton = { text: string; url?: string; data?: string };
|
|
|
35
35
|
* A tool invocation that ran during an assistant turn, persisted so history
|
|
36
36
|
* shows what the model did even after a reload/restart (additive in v1 —
|
|
37
37
|
* older clients simply ignore the field).
|
|
38
|
+
*
|
|
39
|
+
* Reply-delivery tools (`end_turn` / `send_message` / `react`) never appear
|
|
40
|
+
* here or in `tool` events — the daemon classifies and excludes them at the
|
|
41
|
+
* source, because their effect already reaches clients as the `message` /
|
|
42
|
+
* `reaction` itself. Clients render tool timelines as-is, no name filtering.
|
|
38
43
|
*/
|
|
39
44
|
export type ClientToolCall = {
|
|
40
45
|
id: string;
|