talon-agent 1.34.0 → 1.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/bootstrap.ts +5 -1
- package/src/core/engine/gateway-actions/index.ts +3 -0
- package/src/core/engine/gateway-actions/mesh.ts +22 -0
- package/src/core/engine/gateway.ts +7 -6
- package/src/core/mesh/index.ts +27 -0
- package/src/core/mesh/registry.ts +272 -0
- package/src/core/mesh/service.ts +550 -0
- package/src/core/mesh/types.ts +137 -0
- package/src/core/tools/index.ts +31 -0
- package/src/core/tools/mesh.ts +71 -0
- package/src/core/tools/messaging.ts +5 -0
- package/src/core/tools/types.ts +13 -1
- 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/actions.ts +17 -1
- package/src/frontend/native/index.ts +48 -1
- package/src/frontend/native/protocol.ts +35 -0
- package/src/frontend/native/server.ts +38 -0
|
@@ -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
|
|
@@ -52,6 +52,20 @@ function toButtons(rows: unknown): ClientButton[][] | undefined {
|
|
|
52
52
|
return mapped.length ? mapped : undefined;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/** Actions this handler owns. Everything else must return null so the
|
|
56
|
+
* gateway can try plugin + shared actions (history, web, cron, mesh…) —
|
|
57
|
+
* failing early here would swallow actions that aren't native's to answer. */
|
|
58
|
+
const NATIVE_ACTIONS = new Set([
|
|
59
|
+
"send_message",
|
|
60
|
+
"send_message_with_buttons",
|
|
61
|
+
"send_photo",
|
|
62
|
+
"react",
|
|
63
|
+
"edit_message",
|
|
64
|
+
"delete_message",
|
|
65
|
+
"get_chat_info",
|
|
66
|
+
"send_chat_action",
|
|
67
|
+
]);
|
|
68
|
+
|
|
55
69
|
export function createNativeActionHandler(
|
|
56
70
|
deps: NativeActionDeps,
|
|
57
71
|
): FrontendActionHandler {
|
|
@@ -59,6 +73,7 @@ export function createNativeActionHandler(
|
|
|
59
73
|
|
|
60
74
|
return async (body, chatId): Promise<ActionResult | null> => {
|
|
61
75
|
const action = typeof body.action === "string" ? body.action : "";
|
|
76
|
+
if (!NATIVE_ACTIONS.has(action)) return null;
|
|
62
77
|
const entry = chats.byNumeric(chatId);
|
|
63
78
|
if (!entry) return { ok: false, error: "No active native chat" };
|
|
64
79
|
|
|
@@ -126,7 +141,8 @@ export function createNativeActionHandler(
|
|
|
126
141
|
return { ok: true };
|
|
127
142
|
|
|
128
143
|
default:
|
|
129
|
-
//
|
|
144
|
+
// Unreachable — NATIVE_ACTIONS gates the switch — but keeps the
|
|
145
|
+
// contract explicit if the set and the cases ever drift.
|
|
130
146
|
return null;
|
|
131
147
|
}
|
|
132
148
|
};
|
|
@@ -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,
|
|
@@ -65,6 +66,7 @@ import { NativeChats, DEFAULT_CHAT_TITLE, type ChatEntry } from "./chats.js";
|
|
|
65
66
|
import { extractSessionName } from "../../backend/shared/session-name.js";
|
|
66
67
|
import { BridgeServer, type BridgeServerHandlers } from "./server.js";
|
|
67
68
|
import { createNativeActionHandler } from "./actions.js";
|
|
69
|
+
import { getMeshService } from "../../core/mesh/index.js";
|
|
68
70
|
import { removeBridgeDiscovery, writeBridgeDiscovery } from "./discovery.js";
|
|
69
71
|
import { readLogEntries } from "./logs.js";
|
|
70
72
|
import {
|
|
@@ -147,6 +149,13 @@ export function createNativeFrontend(
|
|
|
147
149
|
const startedAt = new Date().toISOString();
|
|
148
150
|
const botName = config.botDisplayName || "Talon";
|
|
149
151
|
const chats = new NativeChats();
|
|
152
|
+
// Daemon-wide mesh service (core/mesh). This frontend is its transport:
|
|
153
|
+
// companions register/report/answer through the bridge routes below, and
|
|
154
|
+
// the SSE transport (wired in init) pushes locate requests and device
|
|
155
|
+
// commands out. The model's mesh tools are served by the shared gateway
|
|
156
|
+
// actions, so they work from every frontend.
|
|
157
|
+
const mesh = getMeshService();
|
|
158
|
+
let unregisterMeshTransport: (() => void) | null = null;
|
|
150
159
|
|
|
151
160
|
// Monotonic message-id minter. Seeded from the wall clock so ids stay
|
|
152
161
|
// unique and ascending across restarts (history rows persist their ids).
|
|
@@ -576,6 +585,12 @@ export function createNativeFrontend(
|
|
|
576
585
|
broadcast({ kind: "delta", chatId: entry.id, text: event.text });
|
|
577
586
|
break;
|
|
578
587
|
case "tool_call": {
|
|
588
|
+
// Delivery plumbing (end_turn / send_message / react) never
|
|
589
|
+
// enters the tool timeline — its effect arrives as the
|
|
590
|
+
// `message`/reaction itself. Skipping here keeps the live
|
|
591
|
+
// stream, the mid-turn replay to late joiners, and the
|
|
592
|
+
// persisted turn meta consistent from one choke point.
|
|
593
|
+
if (isDeliveryTool(event.name)) break;
|
|
579
594
|
const input = toolInputToRecord(event.name, event.input);
|
|
580
595
|
turnTools.set(event.id, {
|
|
581
596
|
call: { id: event.id, name: event.name, input },
|
|
@@ -592,6 +607,7 @@ export function createNativeFrontend(
|
|
|
592
607
|
break;
|
|
593
608
|
}
|
|
594
609
|
case "tool_result": {
|
|
610
|
+
if (isDeliveryTool(event.name)) break;
|
|
595
611
|
const output = summarizeToolResult(event.result);
|
|
596
612
|
const live = turnTools.get(event.id);
|
|
597
613
|
if (live) {
|
|
@@ -724,6 +740,7 @@ export function createNativeFrontend(
|
|
|
724
740
|
return {
|
|
725
741
|
app: "talon-bridge",
|
|
726
742
|
protocol: BRIDGE_PROTOCOL_VERSION,
|
|
743
|
+
capabilities: ["mesh", "mesh-commands"],
|
|
727
744
|
botName,
|
|
728
745
|
backend: config.backend,
|
|
729
746
|
model: resolveModel(config.model)?.displayName ?? config.model,
|
|
@@ -1027,7 +1044,11 @@ export function createNativeFrontend(
|
|
|
1027
1044
|
if (msg.role === "assistant") {
|
|
1028
1045
|
const meta = getTurnMeta(id, msg.id);
|
|
1029
1046
|
if (meta) {
|
|
1030
|
-
|
|
1047
|
+
// Delivery tools are excluded at record time, but metas
|
|
1048
|
+
// persisted by older daemons still carry them — filter on
|
|
1049
|
+
// the way out so upgraded installs get clean history too.
|
|
1050
|
+
const tools = meta.tools?.filter((t) => !isDeliveryTool(t.name));
|
|
1051
|
+
if (tools?.length) msg.tools = tools;
|
|
1031
1052
|
if (meta.durationMs) msg.durationMs = meta.durationMs;
|
|
1032
1053
|
if (meta.tokensIn) msg.tokensIn = meta.tokensIn;
|
|
1033
1054
|
if (meta.tokensOut) msg.tokensOut = meta.tokensOut;
|
|
@@ -1149,6 +1170,12 @@ export function createNativeFrontend(
|
|
|
1149
1170
|
readLogEntries(files.log, { limit: lines, minLevel, component }),
|
|
1150
1171
|
liveTurnEvents,
|
|
1151
1172
|
mediaPath: (id) => media.get(id) ?? null,
|
|
1173
|
+
// Mesh routes are thin transport shims over the shared core service —
|
|
1174
|
+
// storeLocation wakes any pending fresh-fix waiters inside the service.
|
|
1175
|
+
registerDevice: (body) => mesh.register(body),
|
|
1176
|
+
storeLocation: (body) => mesh.storeLocation(body),
|
|
1177
|
+
listDevices: () => mesh.list(),
|
|
1178
|
+
completeCommand: (body) => mesh.completeCommand(body),
|
|
1152
1179
|
};
|
|
1153
1180
|
|
|
1154
1181
|
const nativeCfg = config.native ?? { port: 19880, host: "127.0.0.1" };
|
|
@@ -1190,6 +1217,24 @@ export function createNativeFrontend(
|
|
|
1190
1217
|
getBridgePort: () => gateway.getPort(),
|
|
1191
1218
|
|
|
1192
1219
|
async init() {
|
|
1220
|
+
await mesh.load();
|
|
1221
|
+
// Plug this bridge in as the mesh's transport: locates and device
|
|
1222
|
+
// commands (from ANY frontend's mesh tool calls) fan out to every
|
|
1223
|
+
// connected companion client as SSE events; each client filters by
|
|
1224
|
+
// its own device id.
|
|
1225
|
+
unregisterMeshTransport = mesh.registerTransport({
|
|
1226
|
+
locate: (deviceId) => broadcast({ kind: "locate", deviceId }),
|
|
1227
|
+
command: (command) =>
|
|
1228
|
+
broadcast({
|
|
1229
|
+
kind: "device_command",
|
|
1230
|
+
id: command.id,
|
|
1231
|
+
deviceId: command.deviceId,
|
|
1232
|
+
name: command.name,
|
|
1233
|
+
params: command.params,
|
|
1234
|
+
}),
|
|
1235
|
+
});
|
|
1236
|
+
// Mesh tool actions (list_devices / get_device_location) are shared
|
|
1237
|
+
// gateway actions now — no native-only cases here.
|
|
1193
1238
|
gateway.registerFrontendHandler(
|
|
1194
1239
|
"native",
|
|
1195
1240
|
createNativeActionHandler({
|
|
@@ -1219,6 +1264,8 @@ export function createNativeFrontend(
|
|
|
1219
1264
|
},
|
|
1220
1265
|
|
|
1221
1266
|
async stop() {
|
|
1267
|
+
unregisterMeshTransport?.();
|
|
1268
|
+
unregisterMeshTransport = null;
|
|
1222
1269
|
await removeBridgeDiscovery();
|
|
1223
1270
|
await server.stop();
|
|
1224
1271
|
await gateway.stop();
|
|
@@ -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;
|
|
@@ -134,6 +139,8 @@ export type ClientChat = {
|
|
|
134
139
|
export type BridgeStatus = {
|
|
135
140
|
app: "talon-bridge";
|
|
136
141
|
protocol: number;
|
|
142
|
+
/** Additive bridge features supported by this daemon. */
|
|
143
|
+
capabilities?: string[];
|
|
137
144
|
botName: string;
|
|
138
145
|
backend: string;
|
|
139
146
|
/** Display name of the global default model. */
|
|
@@ -190,6 +197,18 @@ export type BackendOption = {
|
|
|
190
197
|
label: string;
|
|
191
198
|
};
|
|
192
199
|
|
|
200
|
+
// Mesh device shapes are canonical in core (the mesh is daemon-wide state,
|
|
201
|
+
// readable from every frontend); re-exported here so bridge clients keep
|
|
202
|
+
// depending on the protocol module alone.
|
|
203
|
+
export type {
|
|
204
|
+
DeviceCommand,
|
|
205
|
+
DeviceCommandResult,
|
|
206
|
+
DeviceInfo,
|
|
207
|
+
DeviceLocation,
|
|
208
|
+
DevicePlatform,
|
|
209
|
+
} from "../../core/mesh/types.js";
|
|
210
|
+
import type { DeviceCommand as MeshDeviceCommand } from "../../core/mesh/types.js";
|
|
211
|
+
|
|
193
212
|
/**
|
|
194
213
|
* Server → client events, delivered as SSE `data:` lines (one JSON object
|
|
195
214
|
* each). The client switches on `kind`. Streaming text arrives as ephemeral
|
|
@@ -228,6 +247,20 @@ export type BridgeEvent =
|
|
|
228
247
|
durationMs?: number;
|
|
229
248
|
usage?: { input: number; output: number };
|
|
230
249
|
}
|
|
250
|
+
| { kind: "locate"; deviceId?: string }
|
|
251
|
+
/**
|
|
252
|
+
* On-demand device command (ring, open_url, clipboard_*, status, …). The
|
|
253
|
+
* target device executes it and answers via POST /devices/command-result
|
|
254
|
+
* with the same `id` as `commandId`. Additive in v1 — app builds that
|
|
255
|
+
* predate the command channel simply ignore the event.
|
|
256
|
+
*/
|
|
257
|
+
| {
|
|
258
|
+
kind: "device_command";
|
|
259
|
+
id: string;
|
|
260
|
+
deviceId: string;
|
|
261
|
+
name: string;
|
|
262
|
+
params: MeshDeviceCommand["params"];
|
|
263
|
+
}
|
|
231
264
|
| { kind: "error"; chatId?: string; message: string };
|
|
232
265
|
|
|
233
266
|
// ── Mappers (Talon internals → wire types) ───────────────────────────────────
|
|
@@ -251,3 +284,5 @@ export function historyToClientMessage(
|
|
|
251
284
|
ts: m.timestamp,
|
|
252
285
|
};
|
|
253
286
|
}
|
|
287
|
+
|
|
288
|
+
export { toDeviceInfo, toDeviceLocation } from "../../core/mesh/types.js";
|