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
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bind one remote-server backend from its profile.
|
|
3
|
+
*
|
|
4
|
+
* Kilo is a fork of OpenCode and exposes the same HTTP API, so everything
|
|
5
|
+
* a backend in this family does — spawn or reuse the local server,
|
|
6
|
+
* register the chat and plugin MCP servers, create sessions, resolve a
|
|
7
|
+
* model's provider — is shared code acting on a per-backend
|
|
8
|
+
* `RemoteServerState`. What actually differs between the two backends is
|
|
9
|
+
* the handful of fields on `RemoteBackendProfile`: the SDK's client and
|
|
10
|
+
* server constructors, the loopback port, the delivery contract, and how
|
|
11
|
+
* a stored model string splits into provider/model ids.
|
|
12
|
+
*
|
|
13
|
+
* Before this module each backend carried its own ~300-line `server.ts`
|
|
14
|
+
* of hand-written pass-through wrappers around the shared helpers. They
|
|
15
|
+
* drifted in the small ways copies do (one documented the port override,
|
|
16
|
+
* the other didn't; one exported `errMsg`, the other aliased it) while
|
|
17
|
+
* doing exactly the same thing. Now a backend's `server.ts` declares its
|
|
18
|
+
* profile and re-exports these bindings under the historical names —
|
|
19
|
+
* the surface the models module, the tests, and
|
|
20
|
+
* `vi.mock("../backend/<name>/server.js")` all address.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { TalonConfig } from "../../util/config.js";
|
|
24
|
+
import type { FrontendName } from "../../core/agent-runtime/backend-registry.js";
|
|
25
|
+
import {
|
|
26
|
+
buildDeliveryContract,
|
|
27
|
+
type DeliveryMode,
|
|
28
|
+
} from "../shared/delivery-contract.js";
|
|
29
|
+
import type { RemoteAgentClient } from "./client.js";
|
|
30
|
+
import { createRemoteServerState, errMsg } from "./state.js";
|
|
31
|
+
import { ensureRemoteServer, stopRemoteServer } from "./lifecycle.js";
|
|
32
|
+
import {
|
|
33
|
+
ensureChatMcpServer as ensureChatMcpServerShared,
|
|
34
|
+
ensurePluginMcpServers as ensurePluginMcpServersShared,
|
|
35
|
+
buildToolOverrides as buildToolOverridesShared,
|
|
36
|
+
disconnectChatMcpServer as disconnectChatMcpServerShared,
|
|
37
|
+
refreshPluginMcpServers as refreshPluginMcpServersShared,
|
|
38
|
+
getRegisteredMcpServerNames as getRegisteredMcpServerNamesShared,
|
|
39
|
+
} from "./mcp.js";
|
|
40
|
+
import { ensureRemoteSession, warmRemoteSession } from "./sessions.js";
|
|
41
|
+
import { resolveProviderID as resolveProviderIDShared } from "./providers.js";
|
|
42
|
+
import { guessProviderID, getBucketPriority } from "./model-catalog/index.js";
|
|
43
|
+
|
|
44
|
+
/** Every backend in the family binds to loopback — never exposed externally. */
|
|
45
|
+
const LOOPBACK_HOSTNAME = "127.0.0.1";
|
|
46
|
+
|
|
47
|
+
/** A stored model value split into the ids the server's prompt API wants. */
|
|
48
|
+
export interface RemoteModelSelection {
|
|
49
|
+
providerID?: string;
|
|
50
|
+
modelID: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The per-backend seams. Everything else in the family is shared. */
|
|
54
|
+
export interface RemoteBackendProfile<TClient extends RemoteAgentClient> {
|
|
55
|
+
/** Display label for log lines and error text ("Kilo", "OpenCode"). */
|
|
56
|
+
label: string;
|
|
57
|
+
/** Loopback port the local server listens on by default. */
|
|
58
|
+
defaultPort: number;
|
|
59
|
+
/**
|
|
60
|
+
* Env var that overrides the port, so integration tests can spawn an
|
|
61
|
+
* isolated server alongside a running production Talon that holds the
|
|
62
|
+
* default.
|
|
63
|
+
*/
|
|
64
|
+
portEnv: string;
|
|
65
|
+
/** Delivery contract the system-prompt suffix carries. */
|
|
66
|
+
deliveryContract: DeliveryMode;
|
|
67
|
+
/** Strict SDK client over an already-running server URL. */
|
|
68
|
+
createClient(baseUrl: string): TClient;
|
|
69
|
+
/** Spawn a fresh local server; `close()` runs from `stop()`. */
|
|
70
|
+
createServer(args: {
|
|
71
|
+
hostname: string;
|
|
72
|
+
port: number;
|
|
73
|
+
timeout: number;
|
|
74
|
+
}): Promise<{ url: string; close(): void }>;
|
|
75
|
+
/** Split a stored model-selection string into provider/model ids. */
|
|
76
|
+
parseModelSelection(value: string): RemoteModelSelection;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The bound backend: the shared helpers closed over this backend's state. */
|
|
80
|
+
export interface RemoteServerBindings<TClient extends RemoteAgentClient> {
|
|
81
|
+
/** `http://127.0.0.1:<port>` — where the local server is expected. */
|
|
82
|
+
readonly baseUrl: string;
|
|
83
|
+
/** Delivery-contract suffix for a given frontend's tool names. */
|
|
84
|
+
systemPromptSuffix(frontend: string): string;
|
|
85
|
+
/** Telegram-shaped suffix, kept for one-shot (cross-surface) paths. */
|
|
86
|
+
readonly defaultSystemPromptSuffix: string;
|
|
87
|
+
/**
|
|
88
|
+
* Store the config, gateway-port resolver, and frontend label. Does
|
|
89
|
+
* NOT spawn the server — that happens lazily on the first
|
|
90
|
+
* `ensureServer()` so heartbeat-only installs don't pay for it.
|
|
91
|
+
*/
|
|
92
|
+
init(
|
|
93
|
+
cfg: TalonConfig,
|
|
94
|
+
getGatewayPort?: () => number,
|
|
95
|
+
frontend?: FrontendName,
|
|
96
|
+
): void;
|
|
97
|
+
/**
|
|
98
|
+
* Register a callback to run whenever the server is stopped. Cache
|
|
99
|
+
* invalidation lives with the caches: the models module registers its
|
|
100
|
+
* clear here at load time, so the server never imports it (a cycle).
|
|
101
|
+
*/
|
|
102
|
+
onServerStop(hook: () => void): void;
|
|
103
|
+
/**
|
|
104
|
+
* Stop the local server if we own it and clear caches. Idempotent; a
|
|
105
|
+
* reused pre-existing server is left running.
|
|
106
|
+
*/
|
|
107
|
+
stop(): void;
|
|
108
|
+
/** Lazily start (or reuse) the local server and return a strict client. */
|
|
109
|
+
ensureServer(): Promise<TClient>;
|
|
110
|
+
ensureChatMcpServer(oc: TClient, chatId: string): Promise<string>;
|
|
111
|
+
ensurePluginMcpServers(oc: TClient, chatId: string): Promise<string[]>;
|
|
112
|
+
/**
|
|
113
|
+
* A `tools` override map enabling ONLY this chat's Talon tools. Session
|
|
114
|
+
* permission rules cover execution; this covers visibility, because the
|
|
115
|
+
* server exposes every registered MCP server's tools to every session.
|
|
116
|
+
*/
|
|
117
|
+
buildToolOverrides(
|
|
118
|
+
oc: TClient,
|
|
119
|
+
chatServerName: string,
|
|
120
|
+
pluginServerNames?: readonly string[],
|
|
121
|
+
): Promise<Record<string, boolean> | undefined>;
|
|
122
|
+
disconnectChatMcpServer(oc: TClient, serverName: string): Promise<void>;
|
|
123
|
+
refreshPluginMcpServers(chatId: string): Promise<{
|
|
124
|
+
added: string[];
|
|
125
|
+
removed: string[];
|
|
126
|
+
errors: Record<string, string>;
|
|
127
|
+
}>;
|
|
128
|
+
updateSystemPrompt(prompt: string): void;
|
|
129
|
+
/**
|
|
130
|
+
* Resume the stored session id if the server confirms it is alive;
|
|
131
|
+
* otherwise create a fresh one with Talon's standard permission ruleset.
|
|
132
|
+
*/
|
|
133
|
+
ensureSession(oc: TClient, chatId: string): Promise<string>;
|
|
134
|
+
/** Front-load a chat's cold start after `/reset`. Never throws. */
|
|
135
|
+
warmSession(chatId: string): Promise<void>;
|
|
136
|
+
/** Resolve a model id to its provider id against the live catalog. */
|
|
137
|
+
resolveProviderID(oc: TClient, modelID: string): Promise<string>;
|
|
138
|
+
parseModelSelection(value: string): RemoteModelSelection;
|
|
139
|
+
/** Throws if `init` has not run. */
|
|
140
|
+
getConfig(): TalonConfig;
|
|
141
|
+
/**
|
|
142
|
+
* Locally-cached MCP registrations. Test-only: `GET /mcp` returns `{}`
|
|
143
|
+
* regardless of state on both servers.
|
|
144
|
+
*/
|
|
145
|
+
getRegisteredMcpServerNames(): string[];
|
|
146
|
+
errMsg(e: unknown): string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function bindRemoteServer<TClient extends RemoteAgentClient>(
|
|
150
|
+
profile: RemoteBackendProfile<TClient>,
|
|
151
|
+
): RemoteServerBindings<TClient> {
|
|
152
|
+
const port = Number(process.env[profile.portEnv] ?? profile.defaultPort);
|
|
153
|
+
const state = createRemoteServerState<TClient>({
|
|
154
|
+
label: profile.label,
|
|
155
|
+
hostname: LOOPBACK_HOSTNAME,
|
|
156
|
+
port,
|
|
157
|
+
});
|
|
158
|
+
const stopHooks = new Set<() => void>();
|
|
159
|
+
|
|
160
|
+
const systemPromptSuffix = (frontend: string): string =>
|
|
161
|
+
`\n\n${buildDeliveryContract(profile.deliveryContract, frontend)}\n`;
|
|
162
|
+
|
|
163
|
+
const ensureServer = (): Promise<TClient> =>
|
|
164
|
+
ensureRemoteServer({
|
|
165
|
+
state,
|
|
166
|
+
createClient: profile.createClient,
|
|
167
|
+
createServer: profile.createServer,
|
|
168
|
+
});
|
|
169
|
+
const ensureSession = (oc: TClient, chatId: string): Promise<string> =>
|
|
170
|
+
ensureRemoteSession(oc, state, chatId);
|
|
171
|
+
const ensureChatMcpServer = (oc: TClient, chatId: string): Promise<string> =>
|
|
172
|
+
ensureChatMcpServerShared(oc, state, chatId);
|
|
173
|
+
const ensurePluginMcpServers = (
|
|
174
|
+
oc: TClient,
|
|
175
|
+
chatId: string,
|
|
176
|
+
): Promise<string[]> => ensurePluginMcpServersShared(oc, state, chatId);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
baseUrl: state.baseUrl,
|
|
180
|
+
systemPromptSuffix,
|
|
181
|
+
defaultSystemPromptSuffix: systemPromptSuffix("telegram"),
|
|
182
|
+
init(cfg, getGatewayPort, frontend) {
|
|
183
|
+
state.config = cfg;
|
|
184
|
+
if (getGatewayPort) state.gatewayPortFn = getGatewayPort;
|
|
185
|
+
if (frontend) state.frontendName = frontend;
|
|
186
|
+
},
|
|
187
|
+
onServerStop(hook) {
|
|
188
|
+
stopHooks.add(hook);
|
|
189
|
+
},
|
|
190
|
+
stop() {
|
|
191
|
+
stopRemoteServer(state, () => {
|
|
192
|
+
for (const hook of stopHooks) hook();
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
ensureServer,
|
|
196
|
+
ensureChatMcpServer,
|
|
197
|
+
ensurePluginMcpServers,
|
|
198
|
+
buildToolOverrides: (oc, chatServerName, pluginServerNames = []) =>
|
|
199
|
+
buildToolOverridesShared(oc, state, chatServerName, pluginServerNames),
|
|
200
|
+
disconnectChatMcpServer: (oc, serverName) =>
|
|
201
|
+
disconnectChatMcpServerShared(oc, state, serverName),
|
|
202
|
+
refreshPluginMcpServers: async (chatId) =>
|
|
203
|
+
refreshPluginMcpServersShared(await ensureServer(), state, chatId),
|
|
204
|
+
updateSystemPrompt(prompt) {
|
|
205
|
+
if (state.config) state.config.systemPrompt = prompt;
|
|
206
|
+
},
|
|
207
|
+
ensureSession,
|
|
208
|
+
warmSession: (chatId) =>
|
|
209
|
+
warmRemoteSession(state, chatId, {
|
|
210
|
+
ensureServer,
|
|
211
|
+
ensureSession,
|
|
212
|
+
ensureChatMcpServer,
|
|
213
|
+
ensurePluginMcpServers,
|
|
214
|
+
}),
|
|
215
|
+
resolveProviderID: (oc, modelID) =>
|
|
216
|
+
resolveProviderIDShared(oc, state, modelID, {
|
|
217
|
+
guessProviderID,
|
|
218
|
+
getBucketPriority,
|
|
219
|
+
}),
|
|
220
|
+
parseModelSelection: profile.parseModelSelection,
|
|
221
|
+
getConfig() {
|
|
222
|
+
if (!state.config) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`${profile.label} agent not initialized — call init${profile.label}Agent first`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return state.config;
|
|
228
|
+
},
|
|
229
|
+
getRegisteredMcpServerNames: () => getRegisteredMcpServerNamesShared(state),
|
|
230
|
+
errMsg,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
@@ -1,29 +1,60 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Run one
|
|
2
|
+
* Run one turn over the remote server's HTTP API + SSE event stream.
|
|
3
3
|
*
|
|
4
|
-
* `
|
|
5
|
-
* events are lost), fires `promptAsync`, awaits the SSE close, then reads
|
|
6
|
-
* authoritative parts list. `subscribeToTurnEvents` translates
|
|
7
|
-
* events into shared stream-state mutations and observes
|
|
4
|
+
* `runRemoteTurn` subscribes to SSE before issuing the prompt (so no early
|
|
5
|
+
* events are lost), fires `promptAsync`, awaits the SSE close, then reads
|
|
6
|
+
* the authoritative parts list. `subscribeToTurnEvents` translates the
|
|
7
|
+
* relevant SSE events into shared stream-state mutations and observes the
|
|
8
|
+
* turn going idle.
|
|
9
|
+
*
|
|
10
|
+
* Kilo and OpenCode share the SSE schema (Kilo is a fork), so this is one
|
|
11
|
+
* implementation parameterised by the backend label. The two per-backend
|
|
12
|
+
* copies it replaces had already drifted apart in comments only.
|
|
8
13
|
*/
|
|
9
14
|
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
15
|
+
import { logWarn } from "../../util/log.js";
|
|
16
|
+
import { createStreamState, recordTokens, sleep } from "../shared/index.js";
|
|
17
|
+
import { processStreamEvent, finalizePartsIntoState } from "./events.js";
|
|
18
|
+
import { findLastAssistantMessage } from "./messages.js";
|
|
13
19
|
import {
|
|
14
20
|
approvePendingPermissions,
|
|
15
21
|
extractAssistantUsage,
|
|
22
|
+
extractPartsSummary,
|
|
16
23
|
rejectPendingQuestions,
|
|
17
|
-
type
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
type RemoteAssistantInfo,
|
|
25
|
+
type RemoteSessionClient,
|
|
26
|
+
} from "./session-helpers.js";
|
|
27
|
+
import {
|
|
28
|
+
subscribeSseStream,
|
|
29
|
+
type SseSubscribableClient,
|
|
30
|
+
} from "./sse-stream.js";
|
|
31
|
+
import { errMsg } from "./state.js";
|
|
32
|
+
import { awaitRemoteTurn, type RemoteTurnAbortClient } from "./turn-timeout.js";
|
|
24
33
|
|
|
25
|
-
|
|
26
|
-
|
|
34
|
+
/**
|
|
35
|
+
* The slice of the SDK client a turn touches. Both concrete SDK clients
|
|
36
|
+
* satisfy it structurally; callers holding a typed SDK client cast.
|
|
37
|
+
*/
|
|
38
|
+
export interface RemoteTurnClient
|
|
39
|
+
extends SseSubscribableClient, RemoteTurnAbortClient {
|
|
40
|
+
session: RemoteTurnAbortClient["session"] & {
|
|
41
|
+
promptAsync(args: {
|
|
42
|
+
sessionID: string;
|
|
43
|
+
parts: Array<{ type: "text"; text: string }>;
|
|
44
|
+
model: { providerID: string; modelID: string };
|
|
45
|
+
system: string;
|
|
46
|
+
tools?: Record<string, boolean>;
|
|
47
|
+
}): Promise<unknown>;
|
|
48
|
+
messages(args: { sessionID: string }): Promise<{ data?: unknown }>;
|
|
49
|
+
};
|
|
50
|
+
question: RemoteSessionClient["question"];
|
|
51
|
+
permission: RemoteSessionClient["permission"];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RunRemoteTurnInputs {
|
|
55
|
+
/** Backend label for log lines ("Kilo", "OpenCode"). */
|
|
56
|
+
label: string;
|
|
57
|
+
oc: RemoteTurnClient;
|
|
27
58
|
sessionId: string;
|
|
28
59
|
prompt: string;
|
|
29
60
|
systemPrompt: string;
|
|
@@ -41,21 +72,24 @@ export interface RunKiloTurnInputs {
|
|
|
41
72
|
}
|
|
42
73
|
|
|
43
74
|
/**
|
|
44
|
-
* Run one
|
|
75
|
+
* Run one turn end-to-end.
|
|
45
76
|
*
|
|
46
77
|
* Strategy:
|
|
47
78
|
* 1. Subscribe to SSE BEFORE issuing the prompt so no early events are
|
|
48
79
|
* lost. The subscription tracks state mutations and resolves when the
|
|
49
80
|
* turn ends (`session.turn.close` / `session.idle` / `session.error`).
|
|
50
81
|
* 2. Fire the prompt via `session.promptAsync` — returns immediately;
|
|
51
|
-
*
|
|
82
|
+
* the server runs the model task in the background and emits SSE.
|
|
52
83
|
* 3. Await the SSE close event — Talon's await is on event iteration we
|
|
53
84
|
* control, never on a long-running HTTP call we can't interrupt.
|
|
54
85
|
* 4. Read the authoritative parts list via `session.messages` and drain
|
|
55
86
|
* it through `finalizePartsIntoState`.
|
|
56
87
|
*/
|
|
57
|
-
export async function
|
|
88
|
+
export async function runRemoteTurn(
|
|
89
|
+
inputs: RunRemoteTurnInputs,
|
|
90
|
+
): Promise<void> {
|
|
58
91
|
const {
|
|
92
|
+
label,
|
|
59
93
|
oc,
|
|
60
94
|
sessionId,
|
|
61
95
|
prompt,
|
|
@@ -72,12 +106,14 @@ export async function runKiloTurn(inputs: RunKiloTurnInputs): Promise<void> {
|
|
|
72
106
|
onTextBlock,
|
|
73
107
|
onToolUse,
|
|
74
108
|
} = inputs;
|
|
109
|
+
const sessionClient = oc as unknown as RemoteSessionClient;
|
|
75
110
|
|
|
76
|
-
// SSE subscription FIRST —
|
|
77
|
-
// `message.part.updated` events immediately after promptAsync
|
|
78
|
-
// so the iterator must already be alive.
|
|
111
|
+
// SSE subscription FIRST — `session.turn.open` and early
|
|
112
|
+
// `message.part.updated` events can fire immediately after promptAsync
|
|
113
|
+
// returns, so the iterator must already be alive.
|
|
79
114
|
const sseAbort = new AbortController();
|
|
80
115
|
const sseDone = subscribeToTurnEvents({
|
|
116
|
+
label,
|
|
81
117
|
oc,
|
|
82
118
|
sessionId,
|
|
83
119
|
state,
|
|
@@ -91,13 +127,27 @@ export async function runKiloTurn(inputs: RunKiloTurnInputs): Promise<void> {
|
|
|
91
127
|
|
|
92
128
|
// Headless-interaction watchdog: resolve upstream questions and any
|
|
93
129
|
// permission requests that escaped the session ruleset.
|
|
130
|
+
const settlePending = (): Promise<unknown> =>
|
|
131
|
+
Promise.all([
|
|
132
|
+
rejectPendingQuestions(
|
|
133
|
+
sessionClient,
|
|
134
|
+
sessionId,
|
|
135
|
+
chatId,
|
|
136
|
+
seenQuestionIds,
|
|
137
|
+
label,
|
|
138
|
+
),
|
|
139
|
+
approvePendingPermissions(
|
|
140
|
+
sessionClient,
|
|
141
|
+
sessionId,
|
|
142
|
+
chatId,
|
|
143
|
+
seenPermissionIds,
|
|
144
|
+
label,
|
|
145
|
+
),
|
|
146
|
+
]);
|
|
94
147
|
const questionWatchdog = (async () => {
|
|
95
148
|
while (!sseAbort.signal.aborted) {
|
|
96
149
|
try {
|
|
97
|
-
await
|
|
98
|
-
rejectPendingQuestions(oc, sessionId, chatId, seenQuestionIds),
|
|
99
|
-
approvePendingPermissions(oc, sessionId, chatId, seenPermissionIds),
|
|
100
|
-
]);
|
|
150
|
+
await settlePending();
|
|
101
151
|
} catch (err) {
|
|
102
152
|
logWarn(
|
|
103
153
|
"agent",
|
|
@@ -121,11 +171,9 @@ export async function runKiloTurn(inputs: RunKiloTurnInputs): Promise<void> {
|
|
|
121
171
|
system: systemPrompt,
|
|
122
172
|
...(toolOverrides ? { tools: toolOverrides } : {}),
|
|
123
173
|
});
|
|
124
|
-
|
|
125
|
-
// Await turn completion via SSE.
|
|
126
174
|
await sseDone;
|
|
127
175
|
})(),
|
|
128
|
-
{ client: oc, sessionId, chatId, label
|
|
176
|
+
{ client: oc, sessionId, chatId, label },
|
|
129
177
|
);
|
|
130
178
|
|
|
131
179
|
// Read authoritative final state from the messages endpoint. The SSE
|
|
@@ -135,7 +183,8 @@ export async function runKiloTurn(inputs: RunKiloTurnInputs): Promise<void> {
|
|
|
135
183
|
const messages = Array.isArray(messagesResp.data)
|
|
136
184
|
? (messagesResp.data as Array<Record<string, unknown>>)
|
|
137
185
|
: [];
|
|
138
|
-
const lastAssistant =
|
|
186
|
+
const lastAssistant =
|
|
187
|
+
findLastAssistantMessage<RemoteAssistantInfo>(messages);
|
|
139
188
|
const parts = lastAssistant?.parts ?? [];
|
|
140
189
|
const assistantInfo = lastAssistant?.info;
|
|
141
190
|
|
|
@@ -143,6 +192,7 @@ export async function runKiloTurn(inputs: RunKiloTurnInputs): Promise<void> {
|
|
|
143
192
|
parts,
|
|
144
193
|
state,
|
|
145
194
|
seenToolCallIds,
|
|
195
|
+
extractPartsSummary,
|
|
146
196
|
onToolUse,
|
|
147
197
|
});
|
|
148
198
|
|
|
@@ -171,30 +221,16 @@ export async function runKiloTurn(inputs: RunKiloTurnInputs): Promise<void> {
|
|
|
171
221
|
await Promise.race([sseDone.catch(() => {}), sleep(1_000)]);
|
|
172
222
|
await questionWatchdog.catch(() => {});
|
|
173
223
|
try {
|
|
174
|
-
await
|
|
175
|
-
rejectPendingQuestions(oc, sessionId, chatId, seenQuestionIds),
|
|
176
|
-
approvePendingPermissions(oc, sessionId, chatId, seenPermissionIds),
|
|
177
|
-
]);
|
|
224
|
+
await settlePending();
|
|
178
225
|
} catch {
|
|
179
226
|
/* noop */
|
|
180
227
|
}
|
|
181
228
|
}
|
|
182
229
|
}
|
|
183
230
|
|
|
184
|
-
/**
|
|
185
|
-
* Find the most recent assistant message in a session-messages list and
|
|
186
|
-
* surface its parts + assistant info. Kilo's `session.messages` returns the
|
|
187
|
-
* full chronological list; we only care about the last assistant message.
|
|
188
|
-
*/
|
|
189
|
-
const findLastAssistantMessage = (
|
|
190
|
-
messages: Array<Record<string, unknown>>,
|
|
191
|
-
): {
|
|
192
|
-
parts: Array<Record<string, unknown>>;
|
|
193
|
-
info?: KiloAssistantInfo;
|
|
194
|
-
} | null => findLastAssistantMessageShared<KiloAssistantInfo>(messages);
|
|
195
|
-
|
|
196
231
|
interface SubscribeInputs {
|
|
197
|
-
|
|
232
|
+
label: string;
|
|
233
|
+
oc: RemoteTurnClient;
|
|
198
234
|
sessionId: string;
|
|
199
235
|
state: ReturnType<typeof createStreamState>;
|
|
200
236
|
chatId: string;
|
|
@@ -206,13 +242,15 @@ interface SubscribeInputs {
|
|
|
206
242
|
}
|
|
207
243
|
|
|
208
244
|
/**
|
|
209
|
-
* Subscribe to
|
|
210
|
-
* into stream-state mutations / callback firings. Only events scoped
|
|
211
|
-
* `sessionId` are processed; others are dropped silently. Errors are
|
|
212
|
-
* but do not propagate
|
|
245
|
+
* Subscribe to the server's global SSE event stream and translate relevant
|
|
246
|
+
* events into stream-state mutations / callback firings. Only events scoped
|
|
247
|
+
* to our `sessionId` are processed; others are dropped silently. Errors are
|
|
248
|
+
* logged but do not propagate — the messages endpoint is the source of
|
|
249
|
+
* truth for the turn result.
|
|
213
250
|
*/
|
|
214
251
|
async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
|
|
215
252
|
const {
|
|
253
|
+
label,
|
|
216
254
|
oc,
|
|
217
255
|
sessionId,
|
|
218
256
|
state,
|
|
@@ -228,20 +266,18 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
|
|
|
228
266
|
// subscribe-failed warning. Returns `undefined` for both "subscribe
|
|
229
267
|
// rejected" and "no iterable in the response shape".
|
|
230
268
|
const stream = await subscribeSseStream(oc, chatId);
|
|
231
|
-
if (!stream) throw new Error(
|
|
269
|
+
if (!stream) throw new Error(`${label} SSE connection unavailable`);
|
|
232
270
|
|
|
233
271
|
try {
|
|
234
272
|
for await (const evt of stream) {
|
|
235
273
|
if (abortSignal.aborted) break;
|
|
236
274
|
if (!evt || typeof evt !== "object") continue;
|
|
237
275
|
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
276
|
+
// The SSE wire format wraps every event in `{payload: {type,
|
|
277
|
+
// properties}}`. Unwrap here so type/properties land where the rest
|
|
278
|
+
// of the loop expects them.
|
|
241
279
|
const payload =
|
|
242
|
-
evt
|
|
243
|
-
? (evt as { payload?: unknown }).payload
|
|
244
|
-
: evt;
|
|
280
|
+
"payload" in evt ? (evt as { payload?: unknown }).payload : evt;
|
|
245
281
|
if (!payload || typeof payload !== "object") continue;
|
|
246
282
|
const event = payload as {
|
|
247
283
|
type?: string;
|
|
@@ -249,9 +285,10 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
|
|
|
249
285
|
};
|
|
250
286
|
|
|
251
287
|
// session.error is observed here for logging; everything else goes
|
|
252
|
-
// through the shared pure helper.
|
|
288
|
+
// through the shared pure helper. The SSE stream is global, so
|
|
253
289
|
// scope-filter to our own sessionId before attributing the error to
|
|
254
|
-
// this chat.
|
|
290
|
+
// this chat — a heartbeat session.error would otherwise pollute the
|
|
291
|
+
// chat's log.
|
|
255
292
|
if (event.type === "session.error") {
|
|
256
293
|
const props = event.properties ?? {};
|
|
257
294
|
const evtSessionID =
|
|
@@ -280,14 +317,14 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
|
|
|
280
317
|
]
|
|
281
318
|
.filter(Boolean)
|
|
282
319
|
.join(" ");
|
|
283
|
-
logWarn("agent", `[${chatId}]
|
|
320
|
+
logWarn("agent", `[${chatId}] ${label} session.error: ${detail}`);
|
|
284
321
|
// Stash the error message so the handler's delivery branch can
|
|
285
|
-
// surface it as `⚠️
|
|
322
|
+
// surface it as `⚠️ <label>: <message>` instead of silence.
|
|
286
323
|
const msg = errProp.message ?? errProp.name;
|
|
287
324
|
if (msg) state.syntheticError = msg;
|
|
288
325
|
}
|
|
289
|
-
// session.error for OUR session ends the turn —
|
|
290
|
-
// to produce more events for this prompt.
|
|
326
|
+
// session.error for OUR session ends the turn — the server isn't
|
|
327
|
+
// going to produce more events for this prompt.
|
|
291
328
|
return;
|
|
292
329
|
}
|
|
293
330
|
|
|
@@ -296,6 +333,7 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
|
|
|
296
333
|
sessionId,
|
|
297
334
|
state,
|
|
298
335
|
seenToolCallIds,
|
|
336
|
+
backendLabel: label,
|
|
299
337
|
onStreamDelta,
|
|
300
338
|
onTextBlock,
|
|
301
339
|
onToolUse,
|
|
@@ -316,8 +354,6 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
|
|
|
316
354
|
}
|
|
317
355
|
}
|
|
318
356
|
} catch (err) {
|
|
319
|
-
// SSE iteration errors are non-fatal — the messages endpoint is the
|
|
320
|
-
// source of truth for the turn result.
|
|
321
357
|
if (!abortSignal.aborted) {
|
|
322
358
|
logWarn("agent", `[${chatId}] SSE iteration failed: ${errMsg(err)}`);
|
|
323
359
|
}
|