talon-agent 3.33.0 → 3.33.2
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/kilo/factory.ts +39 -119
- package/src/backend/kilo/handler/index.ts +1 -11
- package/src/backend/kilo/handler/message.ts +20 -317
- package/src/backend/kilo/server.ts +55 -272
- package/src/backend/kilo/sessions.ts +5 -83
- package/src/backend/opencode/factory.ts +41 -118
- package/src/backend/opencode/handler/index.ts +1 -11
- package/src/backend/opencode/handler/message.ts +20 -321
- package/src/backend/opencode/server.ts +57 -217
- package/src/backend/opencode/sessions.ts +5 -83
- package/src/backend/remote-server/chat-turn.ts +359 -0
- package/src/backend/remote-server/factory.ts +150 -0
- package/src/backend/remote-server/index.ts +14 -19
- package/src/backend/remote-server/server-bindings.ts +232 -0
- package/src/backend/{kilo/handler → remote-server}/turn.ts +103 -67
- package/src/core/mesh/bridge-links.ts +319 -0
- package/src/core/mesh/common.ts +30 -0
- package/src/core/mesh/device-files.ts +710 -0
- package/src/core/mesh/service.ts +72 -894
- package/src/frontend/discord/handlers/access.ts +7 -56
- package/src/frontend/discord/handlers/state.ts +15 -8
- package/src/frontend/native/server.ts +391 -323
- package/src/frontend/shared/access.ts +152 -0
- package/src/frontend/telegram/handlers/access.ts +8 -25
- package/src/frontend/telegram/handlers/queue.ts +3 -35
- package/src/frontend/telegram/handlers/state.ts +15 -8
- package/src/backend/kilo/events.ts +0 -60
- package/src/backend/kilo/handler/state.ts +0 -9
- package/src/backend/opencode/handler/state.ts +0 -9
- package/src/backend/opencode/handler/turn.ts +0 -314
|
@@ -1,113 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OpenCode session helpers —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Kilo is a fork of OpenCode that exposes the same session message shape
|
|
6
|
-
* and question API, so the parsing / summarisation / snapshot logic
|
|
7
|
-
* lives in the shared module. This file binds the OpenCode backend label
|
|
8
|
-
* and re-exports under OpenCode-named symbols for back-compat with the
|
|
9
|
-
* handler and the public OpenCode barrel.
|
|
2
|
+
* OpenCode session helpers — the shared `remote-server/session-helpers.ts`
|
|
3
|
+
* bound to the OpenCode client, re-exported under OpenCode-named symbols
|
|
4
|
+
* for the handler / index / test imports.
|
|
10
5
|
*/
|
|
11
6
|
|
|
12
7
|
import type { OpencodeClient } from "@opencode-ai/sdk/v2";
|
|
13
8
|
import {
|
|
14
9
|
extractPartsSummary as extractPartsSummaryShared,
|
|
15
|
-
extractAssistantUsage as extractAssistantUsageShared,
|
|
16
10
|
summarizeAssistantMessages,
|
|
17
|
-
getTurnSummary,
|
|
18
11
|
getSessionSnapshot,
|
|
19
|
-
rejectPendingQuestions as rejectPendingQuestionsShared,
|
|
20
|
-
approvePendingPermissions as approvePendingPermissionsShared,
|
|
21
|
-
type RemoteAssistantInfo,
|
|
22
12
|
type RemoteSessionSnapshot,
|
|
23
13
|
type RemoteSessionClient,
|
|
24
14
|
} from "../remote-server/session-helpers.js";
|
|
25
15
|
import { ensureServer } from "./server.js";
|
|
26
16
|
|
|
27
|
-
// ── Constants / type aliases (OpenCode-named surface) ──────────────────────
|
|
28
|
-
|
|
29
|
-
/** Subset of OpenCode's `Message.info` used by Talon for usage accounting. */
|
|
30
|
-
export type OpenCodeAssistantInfo = RemoteAssistantInfo;
|
|
31
|
-
|
|
32
17
|
/** Snapshot of an OpenCode session's lifetime + last-turn assistant info. */
|
|
33
18
|
export type OpenCodeSessionSnapshot = RemoteSessionSnapshot;
|
|
34
19
|
|
|
35
|
-
// ── Re-exports of pure helpers ─────────────────────────────────────────────
|
|
36
|
-
|
|
37
20
|
export const extractPartsSummary = extractPartsSummaryShared;
|
|
38
|
-
export const extractAssistantUsage = extractAssistantUsageShared;
|
|
39
21
|
|
|
40
|
-
/**
|
|
41
|
-
* Summarise a batch of session messages into per-turn usage totals.
|
|
42
|
-
* Re-exported under the OpenCode-prefixed name for back-compat with
|
|
43
|
-
* existing imports.
|
|
44
|
-
*/
|
|
22
|
+
/** Summarise a batch of session messages into per-turn usage totals. */
|
|
45
23
|
export const summarizeOpenCodeAssistantMessages = summarizeAssistantMessages;
|
|
46
24
|
|
|
47
|
-
// ── Session-messages aggregators ──────────────────────────────────────────
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Aggregate the most recent turn's assistant messages into a usage
|
|
51
|
-
* summary. Delegates to the shared helper with an OpenCode-typed client.
|
|
52
|
-
*/
|
|
53
|
-
export function getOpenCodeTurnSummary(
|
|
54
|
-
oc: OpencodeClient,
|
|
55
|
-
sessionId: string,
|
|
56
|
-
minCreatedAt: number,
|
|
57
|
-
): Promise<ReturnType<typeof summarizeAssistantMessages>> {
|
|
58
|
-
return getTurnSummary(
|
|
59
|
-
oc as unknown as RemoteSessionClient,
|
|
60
|
-
sessionId,
|
|
61
|
-
minCreatedAt,
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
25
|
/**
|
|
66
26
|
* Build an {@link OpenCodeSessionSnapshot} for the given session id.
|
|
67
|
-
*
|
|
68
27
|
* Returns `undefined` if no session id was provided.
|
|
69
28
|
*/
|
|
70
29
|
export async function getOpenCodeSessionSnapshot(
|
|
71
30
|
sessionId: string | undefined,
|
|
72
31
|
): Promise<OpenCodeSessionSnapshot | undefined> {
|
|
73
32
|
if (!sessionId) return undefined;
|
|
74
|
-
const oc = await ensureServer();
|
|
33
|
+
const oc: OpencodeClient = await ensureServer();
|
|
75
34
|
return getSessionSnapshot(oc as unknown as RemoteSessionClient, sessionId);
|
|
76
35
|
}
|
|
77
|
-
|
|
78
|
-
// ── Pending-question guard ─────────────────────────────────────────────────
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Auto-respond to pending OpenCode questions for this session. Delegates
|
|
82
|
-
* to the shared helper with the OpenCode backend label.
|
|
83
|
-
*/
|
|
84
|
-
export function rejectPendingQuestions(
|
|
85
|
-
oc: OpencodeClient,
|
|
86
|
-
sessionId: string,
|
|
87
|
-
chatId: string,
|
|
88
|
-
seenQuestionIds: Set<string>,
|
|
89
|
-
): Promise<void> {
|
|
90
|
-
return rejectPendingQuestionsShared(
|
|
91
|
-
oc as unknown as RemoteSessionClient,
|
|
92
|
-
sessionId,
|
|
93
|
-
chatId,
|
|
94
|
-
seenQuestionIds,
|
|
95
|
-
"OpenCode",
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Auto-approve pending OpenCode permissions for this headless session. */
|
|
100
|
-
export function approvePendingPermissions(
|
|
101
|
-
oc: OpencodeClient,
|
|
102
|
-
sessionId: string,
|
|
103
|
-
chatId: string,
|
|
104
|
-
seenPermissionIds: Set<string>,
|
|
105
|
-
): Promise<void> {
|
|
106
|
-
return approvePendingPermissionsShared(
|
|
107
|
-
oc as unknown as RemoteSessionClient,
|
|
108
|
-
sessionId,
|
|
109
|
-
chatId,
|
|
110
|
-
seenPermissionIds,
|
|
111
|
-
"OpenCode",
|
|
112
|
-
);
|
|
113
|
-
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chat-turn orchestration for the remote-server family — one message
|
|
3
|
+
* in, one `QueryResult` out.
|
|
4
|
+
*
|
|
5
|
+
* Resolves the active model against the server's catalog, makes sure the
|
|
6
|
+
* server, session, and this chat's MCP servers exist, builds the prompt
|
|
7
|
+
* pair, drives the turn (`./turn.ts`), then does the post-turn accounting:
|
|
8
|
+
* usage fallback from the session summary, metrics, session id/name
|
|
9
|
+
* persistence, and the shared delivery decision.
|
|
10
|
+
*
|
|
11
|
+
* Kilo and OpenCode ran byte-for-byte copies of this (modulo the backend
|
|
12
|
+
* name in log lines). The copies are gone; `RemoteChatBindings` is the
|
|
13
|
+
* seam a backend supplies, and it is a subset of what `bindRemoteServer`
|
|
14
|
+
* already returns.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
getSession,
|
|
19
|
+
incrementTurns,
|
|
20
|
+
recordUsage,
|
|
21
|
+
setSessionId,
|
|
22
|
+
setSessionName,
|
|
23
|
+
} from "../../storage/sessions.js";
|
|
24
|
+
import { getChatSettings } from "../../storage/chat-settings.js";
|
|
25
|
+
import { log, logError } from "../../util/log.js";
|
|
26
|
+
import { traceMessage } from "../../util/trace.js";
|
|
27
|
+
import type { QueryParams, QueryResult } from "../shared/handler-types.js";
|
|
28
|
+
import { frontendsForChat, nonTerminalFrontends } from "../shared/frontends.js";
|
|
29
|
+
import {
|
|
30
|
+
createStreamState,
|
|
31
|
+
recordTokens,
|
|
32
|
+
finalizeResponseText,
|
|
33
|
+
formatUserPrompt,
|
|
34
|
+
prepareSystemPrompt,
|
|
35
|
+
extractSessionName,
|
|
36
|
+
summarizeUsage,
|
|
37
|
+
routeDelivery,
|
|
38
|
+
applyRetryDecision,
|
|
39
|
+
recordTurnMetrics,
|
|
40
|
+
recordFailedTurnAccounting,
|
|
41
|
+
registerTurnInterrupt,
|
|
42
|
+
} from "../shared/index.js";
|
|
43
|
+
import type { RemoteAgentClient } from "./client.js";
|
|
44
|
+
import type { RemoteServerBindings } from "./server-bindings.js";
|
|
45
|
+
import { getTurnSummary, type RemoteSessionClient } from "./session-helpers.js";
|
|
46
|
+
import { runRemoteTurn, type RemoteTurnClient } from "./turn.js";
|
|
47
|
+
|
|
48
|
+
/** What a backend hands the shared handler: its id/label plus server bindings. */
|
|
49
|
+
export type RemoteChatBindings<TClient extends RemoteAgentClient> = Pick<
|
|
50
|
+
RemoteServerBindings<TClient>,
|
|
51
|
+
| "getConfig"
|
|
52
|
+
| "ensureServer"
|
|
53
|
+
| "parseModelSelection"
|
|
54
|
+
| "resolveProviderID"
|
|
55
|
+
| "ensureSession"
|
|
56
|
+
| "ensureChatMcpServer"
|
|
57
|
+
| "ensurePluginMcpServers"
|
|
58
|
+
| "buildToolOverrides"
|
|
59
|
+
| "systemPromptSuffix"
|
|
60
|
+
> & {
|
|
61
|
+
/** Registry id — the `backend` column in turn metrics ("kilo"). */
|
|
62
|
+
id: string;
|
|
63
|
+
/** Display label for log lines and retry classification ("Kilo"). */
|
|
64
|
+
label: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export async function runRemoteChatTurn<TClient extends RemoteAgentClient>(
|
|
68
|
+
bindings: RemoteChatBindings<TClient>,
|
|
69
|
+
params: QueryParams,
|
|
70
|
+
retried = false,
|
|
71
|
+
): Promise<QueryResult> {
|
|
72
|
+
const { id, label } = bindings;
|
|
73
|
+
const config = bindings.getConfig();
|
|
74
|
+
if (!config) throw new Error(`${label} agent not initialized`);
|
|
75
|
+
|
|
76
|
+
const {
|
|
77
|
+
chatId,
|
|
78
|
+
text,
|
|
79
|
+
senderName,
|
|
80
|
+
senderHandle,
|
|
81
|
+
isGroup,
|
|
82
|
+
messageId,
|
|
83
|
+
onTextBlock,
|
|
84
|
+
onToolUse,
|
|
85
|
+
} = params;
|
|
86
|
+
const t0 = Date.now();
|
|
87
|
+
const session = getSession(chatId);
|
|
88
|
+
const previousTurns = session.turns;
|
|
89
|
+
|
|
90
|
+
// Resolve active model + provider lookup against the server's catalog.
|
|
91
|
+
const chatSettings = getChatSettings(chatId);
|
|
92
|
+
const activeModel = params.model ?? chatSettings.model ?? config.model;
|
|
93
|
+
const { providerID: selectedProviderID, modelID } =
|
|
94
|
+
bindings.parseModelSelection(activeModel);
|
|
95
|
+
|
|
96
|
+
const oc = await bindings.ensureServer();
|
|
97
|
+
const providerID =
|
|
98
|
+
selectedProviderID ?? (await bindings.resolveProviderID(oc, modelID));
|
|
99
|
+
log(
|
|
100
|
+
"agent",
|
|
101
|
+
`[${chatId}] ${label} model resolved: provider=${providerID} model=${modelID}` +
|
|
102
|
+
(selectedProviderID ? "" : " (provider via catalog lookup)"),
|
|
103
|
+
);
|
|
104
|
+
const sessionId = await bindings.ensureSession(oc, chatId);
|
|
105
|
+
const chatMcpServerName = await bindings.ensureChatMcpServer(oc, chatId);
|
|
106
|
+
const pluginMcpServerNames = await bindings.ensurePluginMcpServers(
|
|
107
|
+
oc,
|
|
108
|
+
chatId,
|
|
109
|
+
);
|
|
110
|
+
const toolOverrides = await bindings.buildToolOverrides(
|
|
111
|
+
oc,
|
|
112
|
+
chatMcpServerName,
|
|
113
|
+
pluginMcpServerNames,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Build the prompt (time tag + sender + msg_id reference).
|
|
117
|
+
const prompt = formatUserPrompt({
|
|
118
|
+
text,
|
|
119
|
+
senderName: senderName ?? "user",
|
|
120
|
+
senderHandle,
|
|
121
|
+
isGroup,
|
|
122
|
+
messageId,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Per-session frozen prompt + this backend's delivery suffix.
|
|
126
|
+
const { text: systemPrompt } = prepareSystemPrompt({
|
|
127
|
+
config,
|
|
128
|
+
previousTurns,
|
|
129
|
+
backendSuffix: bindings.systemPromptSuffix(
|
|
130
|
+
frontendsForChat(chatId, nonTerminalFrontends(config.frontend))[0] ??
|
|
131
|
+
"telegram",
|
|
132
|
+
),
|
|
133
|
+
chatId,
|
|
134
|
+
sessionEpoch: session.createdAt,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
log("agent", `[${chatId}] <- (${text.length} chars)`);
|
|
138
|
+
traceMessage(chatId, "in", text, { senderName, isGroup });
|
|
139
|
+
|
|
140
|
+
// Bind the stream state to the chat so token mutators mirror counts
|
|
141
|
+
// into the live-turn overlay — /status updates while the turn runs.
|
|
142
|
+
const state = createStreamState(chatId);
|
|
143
|
+
state.newSessionId = sessionId;
|
|
144
|
+
const turnClient = oc as unknown as RemoteTurnClient;
|
|
145
|
+
// A user interrupt is a synthetic turn terminator: marking the flag
|
|
146
|
+
// before aborting the session routes the close through the same clean
|
|
147
|
+
// path a model-fired end_turn takes (MessageAborted swallowed as the
|
|
148
|
+
// expected close, no retry), settling with whatever the turn produced.
|
|
149
|
+
const unregisterInterrupt = registerTurnInterrupt(chatId, async () => {
|
|
150
|
+
state.turnTerminated = true;
|
|
151
|
+
await turnClient.session.abort({ sessionID: sessionId });
|
|
152
|
+
});
|
|
153
|
+
const promptStartedAt = Date.now();
|
|
154
|
+
const seenQuestionIds = new Set<string>();
|
|
155
|
+
const seenPermissionIds = new Set<string>();
|
|
156
|
+
const seenToolCallIds = new Set<string>();
|
|
157
|
+
|
|
158
|
+
const setupMs = Date.now() - t0;
|
|
159
|
+
let promptMs = 0;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
// Drive the turn: subscribe to SSE events in parallel with promptAsync,
|
|
163
|
+
// surface tool calls + terminator into shared state, and exit when the
|
|
164
|
+
// turn closes / goes idle. `onStreamDelta` is intentionally NOT
|
|
165
|
+
// forwarded — the frontend contract is "send the final reply once", not
|
|
166
|
+
// live edit_message updates. Final delivery happens through
|
|
167
|
+
// `end_turn` / `send`.
|
|
168
|
+
const turnStart = Date.now();
|
|
169
|
+
await runRemoteTurn({
|
|
170
|
+
label,
|
|
171
|
+
oc: turnClient,
|
|
172
|
+
sessionId,
|
|
173
|
+
prompt,
|
|
174
|
+
systemPrompt,
|
|
175
|
+
providerID,
|
|
176
|
+
modelID,
|
|
177
|
+
state,
|
|
178
|
+
chatId,
|
|
179
|
+
seenQuestionIds,
|
|
180
|
+
seenPermissionIds,
|
|
181
|
+
seenToolCallIds,
|
|
182
|
+
toolOverrides,
|
|
183
|
+
onStreamDelta: undefined,
|
|
184
|
+
onTextBlock,
|
|
185
|
+
onToolUse,
|
|
186
|
+
});
|
|
187
|
+
promptMs = Date.now() - turnStart;
|
|
188
|
+
} catch (err) {
|
|
189
|
+
const outcome = await applyRetryDecision({
|
|
190
|
+
err,
|
|
191
|
+
chatId,
|
|
192
|
+
activeModel,
|
|
193
|
+
retried,
|
|
194
|
+
params,
|
|
195
|
+
recurseWithRetried: (p) => runRemoteChatTurn(bindings, p, true),
|
|
196
|
+
backendLabel: label,
|
|
197
|
+
});
|
|
198
|
+
if (outcome.retry) return outcome.retry;
|
|
199
|
+
|
|
200
|
+
// Terminal failure — account for whatever the turn consumed before
|
|
201
|
+
// dying and drop the live overlay (the retry path above did its own
|
|
202
|
+
// accounting inside the recursive attempt).
|
|
203
|
+
recordFailedTurnAccounting({
|
|
204
|
+
backend: id,
|
|
205
|
+
chatId,
|
|
206
|
+
durationMs: Date.now() - t0,
|
|
207
|
+
toolCalls: state.toolCalls,
|
|
208
|
+
apiCalls: state.numApiCalls,
|
|
209
|
+
model: activeModel,
|
|
210
|
+
usage: {
|
|
211
|
+
inputTokens: state.sdkInputTokens,
|
|
212
|
+
outputTokens: state.sdkOutputTokens,
|
|
213
|
+
cacheRead: state.sdkCacheRead,
|
|
214
|
+
cacheWrite: state.sdkCacheWrite,
|
|
215
|
+
},
|
|
216
|
+
contextTokens: state.contextTokens,
|
|
217
|
+
contextWindow: state.contextWindow,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
logError(
|
|
221
|
+
"agent",
|
|
222
|
+
`[${chatId}] ${label} error: ${outcome.classified.message}`,
|
|
223
|
+
);
|
|
224
|
+
throw outcome.classified;
|
|
225
|
+
} finally {
|
|
226
|
+
unregisterInterrupt();
|
|
227
|
+
// Note: we deliberately do NOT disconnect the chat MCP server here.
|
|
228
|
+
// The server is named per-chat so it's safe to keep across turns;
|
|
229
|
+
// re-spawning the subprocess each turn cost ~800ms per message.
|
|
230
|
+
// Per-prompt overrides isolate visibility across concurrent chats.
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Post-loop accounting ──────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
// If the SSE loop missed any usage info, fall back to the session
|
|
236
|
+
// summary endpoint (which always reflects the final server state).
|
|
237
|
+
if (
|
|
238
|
+
state.sdkInputTokens === 0 &&
|
|
239
|
+
state.sdkOutputTokens === 0 &&
|
|
240
|
+
state.sdkCacheRead === 0
|
|
241
|
+
) {
|
|
242
|
+
try {
|
|
243
|
+
const summary = await getTurnSummary(
|
|
244
|
+
oc as unknown as RemoteSessionClient,
|
|
245
|
+
sessionId,
|
|
246
|
+
promptStartedAt,
|
|
247
|
+
);
|
|
248
|
+
if (summary.usage.assistantMessages > 0) {
|
|
249
|
+
recordTokens(state, {
|
|
250
|
+
inputTokens: summary.usage.inputTokens,
|
|
251
|
+
outputTokens: summary.usage.outputTokens,
|
|
252
|
+
cacheRead: summary.usage.cacheRead,
|
|
253
|
+
cacheWrite: summary.usage.cacheWrite,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
// best-effort — session summaries can race on cancellation
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const responseText = finalizeResponseText(state);
|
|
262
|
+
const durationMs = Date.now() - t0;
|
|
263
|
+
recordTurnMetrics({
|
|
264
|
+
chatId,
|
|
265
|
+
backend: id,
|
|
266
|
+
durationMs,
|
|
267
|
+
toolCalls: state.toolCalls,
|
|
268
|
+
apiCalls: state.numApiCalls,
|
|
269
|
+
usage: {
|
|
270
|
+
inputTokens: state.sdkInputTokens,
|
|
271
|
+
outputTokens: state.sdkOutputTokens,
|
|
272
|
+
cacheRead: state.sdkCacheRead,
|
|
273
|
+
cacheWrite: state.sdkCacheWrite,
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
if (state.newSessionId) {
|
|
278
|
+
// setSessionId tolerates "same id" calls — keep it simple.
|
|
279
|
+
const stored = getSession(chatId).sessionId;
|
|
280
|
+
if (stored !== state.newSessionId) {
|
|
281
|
+
setSessionId(chatId, state.newSessionId);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
incrementTurns(chatId);
|
|
286
|
+
recordUsage(chatId, {
|
|
287
|
+
inputTokens: state.sdkInputTokens,
|
|
288
|
+
outputTokens: state.sdkOutputTokens,
|
|
289
|
+
cacheRead: state.sdkCacheRead,
|
|
290
|
+
cacheWrite: state.sdkCacheWrite,
|
|
291
|
+
durationMs,
|
|
292
|
+
model: activeModel,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Set a descriptive session name from the user's first message.
|
|
296
|
+
if (previousTurns === 0) {
|
|
297
|
+
const name = extractSessionName(text);
|
|
298
|
+
if (name) setSessionName(chatId, name);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Delivery — the decision tree shared by every backend ──────────────────
|
|
302
|
+
const delivery = await routeDelivery({
|
|
303
|
+
backendLabel: label,
|
|
304
|
+
chatId,
|
|
305
|
+
state,
|
|
306
|
+
responseText,
|
|
307
|
+
onTextBlock,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
log(
|
|
311
|
+
"agent",
|
|
312
|
+
`[${chatId}] delivery: ${delivery.route} (${delivery.chars} chars)`,
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
log(
|
|
316
|
+
"agent",
|
|
317
|
+
`[${chatId}] -> (${summarizeUsage(
|
|
318
|
+
{
|
|
319
|
+
inputTokens: state.sdkInputTokens,
|
|
320
|
+
outputTokens: state.sdkOutputTokens,
|
|
321
|
+
cacheRead: state.sdkCacheRead,
|
|
322
|
+
cacheWrite: state.sdkCacheWrite,
|
|
323
|
+
},
|
|
324
|
+
{ durationMs, toolCalls: state.toolCalls },
|
|
325
|
+
)} terminator=${state.turnTerminated ? "yes" : "no"} ` +
|
|
326
|
+
`delivered=${state.deliveredTextNorms.length} ` +
|
|
327
|
+
`respLen=${responseText.length} ` +
|
|
328
|
+
`setup=${setupMs}ms turn=${promptMs}ms ` +
|
|
329
|
+
`events=${formatEventCounts(state.eventCounts)})`,
|
|
330
|
+
);
|
|
331
|
+
traceMessage(chatId, "out", responseText, {
|
|
332
|
+
durationMs,
|
|
333
|
+
toolCalls: state.toolCalls,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
text: responseText,
|
|
338
|
+
durationMs,
|
|
339
|
+
inputTokens: state.sdkInputTokens,
|
|
340
|
+
outputTokens: state.sdkOutputTokens,
|
|
341
|
+
cacheRead: state.sdkCacheRead,
|
|
342
|
+
cacheWrite: state.sdkCacheWrite,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Compact summary of which SSE event types fired this turn. `none` for a
|
|
348
|
+
* silent turn (itself a useful diagnostic — the SSE socket dropped or never
|
|
349
|
+
* matched our session id). Otherwise renders as `delta×42,part.updated×1`.
|
|
350
|
+
*/
|
|
351
|
+
function formatEventCounts(counts: Record<string, number>): string {
|
|
352
|
+
const entries = Object.entries(counts);
|
|
353
|
+
if (entries.length === 0) return "none";
|
|
354
|
+
// Trim the noisy `message.` / `session.` prefixes so the line stays
|
|
355
|
+
// readable in the live log tail.
|
|
356
|
+
return entries
|
|
357
|
+
.map(([type, n]) => `${type.replace(/^(message|session)\./, "")}×${n}`)
|
|
358
|
+
.join(",");
|
|
359
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose a registry `BackendFactory` for a remote-server backend.
|
|
3
|
+
*
|
|
4
|
+
* The capability wiring is identical for every member of the family:
|
|
5
|
+
* chat through the shared handler, one-shot through the shared runner, the
|
|
6
|
+
* catalog-driven model slots, session snapshots off the server, plugin-MCP
|
|
7
|
+
* refresh, warm-after-reset, and the system-prompt control. A backend
|
|
8
|
+
* supplies its id/label/SDK name and the bound functions; this returns the
|
|
9
|
+
* factory for `registerBackend`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
BackendFactory,
|
|
14
|
+
FrontendName,
|
|
15
|
+
} from "../../core/agent-runtime/backend-registry.js";
|
|
16
|
+
import type { BackendId } from "../../core/agent-runtime/model-ref.js";
|
|
17
|
+
import {
|
|
18
|
+
composeBackend,
|
|
19
|
+
type BackgroundRunner,
|
|
20
|
+
type ChatBackend,
|
|
21
|
+
type ModelCatalog,
|
|
22
|
+
type SessionBackend,
|
|
23
|
+
type SystemControl,
|
|
24
|
+
type ToolRuntime,
|
|
25
|
+
type UsageTelemetry,
|
|
26
|
+
} from "../../core/agent-runtime/capabilities.js";
|
|
27
|
+
import type { OneShotAgentParams, OneShotUsage } from "../../core/types.js";
|
|
28
|
+
import type { TalonConfig } from "../../util/config.js";
|
|
29
|
+
import { log } from "../../util/log.js";
|
|
30
|
+
import { handlerToEvents } from "../shared/handler-to-events.js";
|
|
31
|
+
import type { QueryParams, QueryResult } from "../shared/handler-types.js";
|
|
32
|
+
import { interruptChatTurn } from "../shared/turn-interrupt.js";
|
|
33
|
+
import type { RemoteModelProvider } from "./model-catalog/provider.js";
|
|
34
|
+
import type { RemoteSessionSnapshot } from "./session-helpers.js";
|
|
35
|
+
|
|
36
|
+
export interface RemoteBackendFactoryInputs {
|
|
37
|
+
/** Registry id — matches `config.backend` ("kilo"). */
|
|
38
|
+
id: BackendId;
|
|
39
|
+
/** Display label ("Kilo"). */
|
|
40
|
+
label: string;
|
|
41
|
+
/** npm package of the SDK, for the startup log line. */
|
|
42
|
+
sdkPackage: string;
|
|
43
|
+
init(
|
|
44
|
+
config: TalonConfig,
|
|
45
|
+
getGatewayPort?: () => number,
|
|
46
|
+
frontend?: FrontendName,
|
|
47
|
+
): void;
|
|
48
|
+
stop(): void;
|
|
49
|
+
handleMessage(params: QueryParams): Promise<QueryResult>;
|
|
50
|
+
runOneShotAgent(params: OneShotAgentParams): Promise<OneShotUsage | void>;
|
|
51
|
+
getSessionSnapshot(
|
|
52
|
+
sessionId: string | undefined,
|
|
53
|
+
): Promise<RemoteSessionSnapshot | undefined>;
|
|
54
|
+
models: RemoteModelProvider;
|
|
55
|
+
refreshPluginMcpServers: ToolRuntime["refreshTools"];
|
|
56
|
+
warmSession(chatId: string): Promise<void>;
|
|
57
|
+
updateSystemPrompt(prompt: string): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createRemoteBackendFactory(
|
|
61
|
+
inputs: RemoteBackendFactoryInputs,
|
|
62
|
+
): BackendFactory {
|
|
63
|
+
const { id, label } = inputs;
|
|
64
|
+
return {
|
|
65
|
+
id,
|
|
66
|
+
label,
|
|
67
|
+
async init(config, ctx) {
|
|
68
|
+
inputs.init(config, ctx.getBridgePort, ctx.frontendName);
|
|
69
|
+
log("bot", `Backend: ${label} (${inputs.sdkPackage})`);
|
|
70
|
+
|
|
71
|
+
const chat: ChatBackend = {
|
|
72
|
+
runChatTurn: (params) =>
|
|
73
|
+
handlerToEvents((p) => inputs.handleMessage(p), params),
|
|
74
|
+
interruptChatTurn: (chatId) => interruptChatTurn(chatId),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const background: BackgroundRunner = {
|
|
78
|
+
runOneShotAgent: (p) => inputs.runOneShotAgent(p),
|
|
79
|
+
// A long-lived HTTP server — no per-query subprocesses, so
|
|
80
|
+
// `evictOrphanSubprocesses` is intentionally not implemented.
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const m = inputs.models;
|
|
84
|
+
const models: ModelCatalog = {
|
|
85
|
+
resolveModelInfo: (q) => m.resolveModel(q),
|
|
86
|
+
// Catalog-driven backend with no canonical default — fall through
|
|
87
|
+
// to `config.backendDefaults.<id>`.
|
|
88
|
+
getDefaultModelId: () => undefined,
|
|
89
|
+
getRawModelInfo: (modelId) => m.getModelInfo(modelId),
|
|
90
|
+
getSettingsPresentation: (activeModel, options) =>
|
|
91
|
+
m.getSettingsPresentation(activeModel, options),
|
|
92
|
+
getProviders: () => m.getProviders(),
|
|
93
|
+
getProviderModels: (p, pg, ps) => m.getProviderModels(p, pg, ps),
|
|
94
|
+
formatModelError: (q, r) => m.formatModelError(q, r),
|
|
95
|
+
listModels: (f) => m.listModels(f),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const usage: UsageTelemetry = {
|
|
99
|
+
getSessionSnapshot: async (sessionId) => {
|
|
100
|
+
const snap = await inputs.getSessionSnapshot(sessionId);
|
|
101
|
+
if (!snap) return undefined;
|
|
102
|
+
return {
|
|
103
|
+
inputTokens: snap.usage?.totalInputTokens,
|
|
104
|
+
outputTokens: snap.usage?.totalOutputTokens,
|
|
105
|
+
cacheRead: snap.usage?.totalCacheRead,
|
|
106
|
+
cacheWrite: snap.usage?.totalCacheWrite,
|
|
107
|
+
contextModelId: snap.assistant?.modelID,
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const tools: ToolRuntime = {
|
|
113
|
+
refreshTools: (chatId) => inputs.refreshPluginMcpServers(chatId),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Session state lives on the server, and `/reset` already clears
|
|
117
|
+
// Talon's stored id centrally (storage/sessions.ts), so the next turn
|
|
118
|
+
// creates a fresh one — no `resetChat` needed. `warmSession`
|
|
119
|
+
// front-loads that creation plus the plugin-MCP sweep, matching what
|
|
120
|
+
// the Claude backend does after a reset.
|
|
121
|
+
const sessions: SessionBackend = {
|
|
122
|
+
warmSession: (chatId) => inputs.warmSession(chatId),
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const control: SystemControl = {
|
|
126
|
+
updateSystemPrompt: (prompt) => inputs.updateSystemPrompt(prompt),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const backend = composeBackend({
|
|
130
|
+
id,
|
|
131
|
+
label,
|
|
132
|
+
cacheMetrics: "readwrite",
|
|
133
|
+
chat,
|
|
134
|
+
background,
|
|
135
|
+
models,
|
|
136
|
+
sessions,
|
|
137
|
+
tools,
|
|
138
|
+
usage,
|
|
139
|
+
control,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
backend,
|
|
144
|
+
cleanup: () => {
|
|
145
|
+
inputs.stop();
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* for MCP registration, session lifecycle, tool listing, and provider
|
|
7
7
|
* resolution).
|
|
8
8
|
*
|
|
9
|
-
* Architecture in
|
|
9
|
+
* Architecture in four layers:
|
|
10
10
|
*
|
|
11
11
|
* - {@link RemoteServerState} (state.ts) — per-backend mutable container.
|
|
12
12
|
* Each concrete backend owns one instance, holding the cached client,
|
|
@@ -16,29 +16,29 @@
|
|
|
16
16
|
* - Lifecycle / MCP / sessions / providers — pure helpers that take a
|
|
17
17
|
* `RemoteAgentClient` + `RemoteServerState` and act on them.
|
|
18
18
|
*
|
|
19
|
-
* -
|
|
20
|
-
* the
|
|
21
|
-
*
|
|
19
|
+
* - Bindings (server-bindings.ts, chat-turn.ts, turn.ts, factory.ts) —
|
|
20
|
+
* the helpers closed over one backend's state, the SSE-driven turn,
|
|
21
|
+
* the chat-turn orchestration, and the registry factory composition.
|
|
22
|
+
* This is where the code that used to be copied per backend lives.
|
|
23
|
+
*
|
|
24
|
+
* - Concrete backends (`backend/opencode`, `backend/kilo`) — a
|
|
25
|
+
* `RemoteBackendProfile` (SDK constructors, port, delivery contract,
|
|
26
|
+
* model-selection parser) plus re-exports under historical names.
|
|
22
27
|
*
|
|
23
28
|
* What's NOT here (intentionally):
|
|
24
29
|
*
|
|
25
|
-
* - SDK-specific event types (Kilo's SSE events, OpenCode's sync
|
|
26
|
-
* `prompt` parts list) — those stay in each backend.
|
|
27
|
-
* - Per-backend model catalog logic (`models.ts`) — Kilo and OpenCode
|
|
28
|
-
* have different bucket priorities, fuzzy-match weights, etc.
|
|
29
30
|
* - Tool definitions, frontend prompt format — those are backend-
|
|
30
31
|
* agnostic and live in `core/` and `backend/shared/`.
|
|
32
|
+
*
|
|
33
|
+
* This barrel exposes the helper layer for tests and the conformance
|
|
34
|
+
* suite; the bindings modules import from the concrete files directly.
|
|
31
35
|
*/
|
|
32
36
|
|
|
33
37
|
export type { RemoteAgentClient } from "./client.js";
|
|
34
38
|
|
|
35
|
-
export {
|
|
36
|
-
type RemoteServerState,
|
|
37
|
-
createRemoteServerState,
|
|
38
|
-
errMsg,
|
|
39
|
-
} from "./state.js";
|
|
39
|
+
export { type RemoteServerState, createRemoteServerState } from "./state.js";
|
|
40
40
|
|
|
41
|
-
export {
|
|
41
|
+
export { stopRemoteServer } from "./lifecycle.js";
|
|
42
42
|
|
|
43
43
|
export {
|
|
44
44
|
TALON_MCP_SERVER_NAME,
|
|
@@ -48,10 +48,5 @@ export {
|
|
|
48
48
|
ensurePluginMcpServers,
|
|
49
49
|
buildToolOverrides,
|
|
50
50
|
disconnectChatMcpServer,
|
|
51
|
-
refreshPluginMcpServers,
|
|
52
51
|
getRegisteredMcpServerNames,
|
|
53
52
|
} from "./mcp.js";
|
|
54
|
-
|
|
55
|
-
export { ensureRemoteSession, warmRemoteSession } from "./sessions.js";
|
|
56
|
-
|
|
57
|
-
export { resolveProviderID } from "./providers.js";
|