talon-agent 5.1.0 → 5.2.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/README.md +3 -1
- package/bin/talon.js +35 -0
- package/package.json +2 -2
- package/prompts/identity.md +10 -2
- package/prompts/system/agent-brief.md +43 -0
- package/src/app.ts +60 -58
- package/src/backend/builtins.ts +26 -7
- package/src/backend/claude-sdk/one-shot.ts +13 -5
- package/src/backend/remote-server/index.ts +6 -4
- package/src/backend/remote-server/model-catalog/index.ts +4 -10
- package/src/backend/remote-server/model-catalog/provider.ts +3 -3
- package/src/backend/remote-server/profiles/bind.ts +225 -0
- package/src/backend/remote-server/profiles/index.ts +10 -0
- package/src/backend/remote-server/profiles/kilo.ts +82 -0
- package/src/backend/remote-server/profiles/opencode.ts +61 -0
- package/src/backend/remote-server/server-bindings.ts +3 -4
- package/src/bootstrap.ts +15 -1
- package/src/cli/chat.ts +5 -0
- package/src/cli/events.ts +9 -0
- package/src/core/agents/context.ts +48 -0
- package/src/core/agents/delivery.ts +167 -0
- package/src/core/agents/index.ts +37 -0
- package/src/core/agents/prompt.ts +116 -0
- package/src/core/agents/registry.ts +426 -0
- package/src/core/agents/runner.ts +448 -0
- package/src/core/agents/types.ts +124 -0
- package/src/core/background/cron/job-oneshot.ts +7 -12
- package/src/core/background/cron/job-prompt.ts +1 -1
- package/src/core/background/{cron/isolated-agent.ts → isolated-agent.ts} +45 -24
- package/src/core/background/run-log.ts +33 -0
- package/src/core/bus/events.ts +49 -2
- package/src/core/config/index.ts +25 -0
- package/src/core/daemon/crash.ts +82 -0
- package/src/core/engine/gateway-actions/agents/control.ts +299 -0
- package/src/core/engine/gateway-actions/agents/index.ts +31 -0
- package/src/core/engine/gateway-actions/agents/report.ts +107 -0
- package/src/core/engine/gateway-actions/index.ts +30 -0
- package/src/core/engine/gateway-actions/native/exec-remote.ts +1 -1
- package/src/core/engine/gateway-actions/native/exec.ts +1 -1
- package/src/core/engine/gateway-actions/native/read.ts +1 -1
- package/src/core/engine/gateway-actions/native/search.ts +1 -1
- package/src/core/engine/gateway-actions/native/teleport.ts +1 -1
- package/src/core/engine/gateway-actions/native/write.ts +1 -1
- package/src/core/engine/gateway-routes.ts +12 -0
- package/src/core/engine/gateway.ts +96 -25
- package/src/core/frontend-runtime/capabilities.ts +18 -0
- package/src/core/frontend-runtime/index.ts +4 -0
- package/src/core/frontend-runtime/lifecycle.ts +33 -0
- package/src/core/frontend-runtime/registry.ts +3 -3
- package/src/core/frontend-runtime/run-loop.ts +59 -0
- package/src/core/mesh/{registry.ts → devices/registry.ts} +4 -4
- package/src/core/mesh/{service.ts → devices/service.ts} +13 -10
- package/src/core/mesh/{teleport.ts → devices/teleport.ts} +2 -2
- package/src/core/mesh/index.ts +6 -2
- package/src/core/mesh/{bridge-links.ts → links/bridge-links.ts} +1 -1
- package/src/core/mesh/{companion-pairing.ts → links/companion-pairing.ts} +1 -1
- package/src/core/mesh/{node-binaries.ts → links/node-binaries.ts} +5 -5
- package/src/core/mesh/{node-provision.ts → links/node-provision.ts} +1 -1
- package/src/core/mesh/{common.ts → tool-surface.ts} +7 -2
- package/src/core/mesh/{device-files.ts → transfers/device-files.ts} +5 -5
- package/src/core/prompt/embedded-prompts.ts +38 -36
- package/src/core/tasks/types.ts +2 -2
- package/src/core/tools/index.ts +5 -0
- package/src/core/tools/ops/agents.ts +195 -0
- package/src/core/tools/ops/bridge.ts +4 -0
- package/src/core/tools/types.ts +1 -0
- package/src/core/types.ts +1 -1
- package/src/frontend/discord/commands/info.ts +1 -1
- package/src/frontend/discord/render.ts +1 -1
- package/src/frontend/native/bridge/routes/mesh.ts +1 -1
- package/src/frontend/native/index.ts +2 -0
- package/src/frontend/presentation/reports.ts +1 -1
- package/src/frontend/teams/index.ts +4 -3
- package/src/frontend/telegram/commands/info.ts +61 -20
- package/src/frontend/telegram/index.ts +27 -4
- package/src/frontend/telegram/render/reports.ts +1 -1
- package/src/frontend/terminal/index.ts +6 -2
- package/src/frontend/whatsapp/connection/connection.ts +62 -11
- package/src/frontend/whatsapp/index.ts +25 -1
- package/src/frontend/whatsapp/runtime.ts +7 -0
- package/src/util/log.ts +264 -18
- package/src/backend/kilo/factory.ts +0 -53
- package/src/backend/kilo/handler/index.ts +0 -2
- package/src/backend/kilo/handler/message.ts +0 -44
- package/src/backend/kilo/index.ts +0 -61
- package/src/backend/kilo/model-provider.ts +0 -36
- package/src/backend/kilo/models/index.ts +0 -55
- package/src/backend/kilo/one-shot.ts +0 -42
- package/src/backend/kilo/server.ts +0 -98
- package/src/backend/kilo/sessions.ts +0 -37
- package/src/backend/opencode/factory.ts +0 -53
- package/src/backend/opencode/handler/index.ts +0 -2
- package/src/backend/opencode/handler/message.ts +0 -44
- package/src/backend/opencode/index.ts +0 -42
- package/src/backend/opencode/model-provider.ts +0 -36
- package/src/backend/opencode/models/index.ts +0 -54
- package/src/backend/opencode/one-shot.ts +0 -42
- package/src/backend/opencode/server.ts +0 -80
- package/src/backend/opencode/sessions.ts +0 -35
- /package/src/core/mesh/{transfers.ts → transfers/transfers.ts} +0 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bind one remote-server driver from a profile.
|
|
3
|
+
*
|
|
4
|
+
* Everything a member of this family does — spawn or reuse the local
|
|
5
|
+
* server, register the chat and plugin MCP servers, create sessions,
|
|
6
|
+
* fetch and render the model catalog, run a chat turn, run a one-shot
|
|
7
|
+
* turn, read back a session snapshot — is shared code in
|
|
8
|
+
* `backend/remote-server/`. What differs between OpenCode and its Kilo
|
|
9
|
+
* fork is a short list of constants and two SDK constructors.
|
|
10
|
+
*
|
|
11
|
+
* `bindRemoteProfile` is where that list becomes a driver. It closes the
|
|
12
|
+
* shared helpers over one profile's state and returns a single object
|
|
13
|
+
* that is simultaneously:
|
|
14
|
+
*
|
|
15
|
+
* - the {@link RemoteServerBindings} the shared turn paths need,
|
|
16
|
+
* - the bound model catalog + `Backend.models` adapter, and
|
|
17
|
+
* - a `RemoteBackendFactoryInputs`, ready for
|
|
18
|
+
* `createRemoteBackendFactory` in `backend/builtins.ts`.
|
|
19
|
+
*
|
|
20
|
+
* Before this module each backend carried a `server.ts`, `sessions.ts`,
|
|
21
|
+
* `models/index.ts`, `model-provider.ts`, `handler/message.ts`,
|
|
22
|
+
* `one-shot.ts`, `index.ts` and `factory.ts` whose entire content was
|
|
23
|
+
* re-exporting these bindings under backend-prefixed names. Those 16
|
|
24
|
+
* files are the profile objects in `./kilo.ts` and `./opencode.ts` now.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { BackendId } from "../../../core/agent-runtime/model-ref.js";
|
|
28
|
+
import type { OneShotAgentParams, OneShotUsage } from "../../../core/types.js";
|
|
29
|
+
import type {
|
|
30
|
+
QueryParams,
|
|
31
|
+
QueryResult,
|
|
32
|
+
} from "../../runtime/turn/handler-types.js";
|
|
33
|
+
import type { DeliveryMode } from "../../runtime/prompt/delivery-contract.js";
|
|
34
|
+
import { runRemoteChatTurn } from "../chat-turn.js";
|
|
35
|
+
import type { RemoteAgentClient } from "../client.js";
|
|
36
|
+
import type { RemoteBackendFactoryInputs } from "../factory.js";
|
|
37
|
+
import {
|
|
38
|
+
createRemoteModelCatalogModule,
|
|
39
|
+
createRemoteModelProvider,
|
|
40
|
+
formatRemoteUnavailableModel,
|
|
41
|
+
getRemoteModelSelectionValue,
|
|
42
|
+
resolveRemoteModelInput,
|
|
43
|
+
type RemoteModelCatalogModule,
|
|
44
|
+
type RemoteProviderClient,
|
|
45
|
+
} from "../model-catalog/index.js";
|
|
46
|
+
import {
|
|
47
|
+
runRemoteOneShotAgent,
|
|
48
|
+
type RemoteOneShotClient,
|
|
49
|
+
} from "../one-shot.js";
|
|
50
|
+
import {
|
|
51
|
+
bindRemoteServer,
|
|
52
|
+
type RemoteModelSelection,
|
|
53
|
+
type RemoteServerBindings,
|
|
54
|
+
} from "../server-bindings.js";
|
|
55
|
+
import {
|
|
56
|
+
getSessionSnapshot,
|
|
57
|
+
type RemoteSessionClient,
|
|
58
|
+
} from "../session-helpers.js";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The client shape a profile's SDK must satisfy: the shared helper
|
|
62
|
+
* surface, the provider catalog, and the session lifecycle the one-shot
|
|
63
|
+
* runner drives directly. Both `OpencodeClient` and `KiloClient` match
|
|
64
|
+
* structurally.
|
|
65
|
+
*/
|
|
66
|
+
export type RemoteProfileClient = RemoteAgentClient &
|
|
67
|
+
RemoteOneShotClient &
|
|
68
|
+
RemoteProviderClient;
|
|
69
|
+
|
|
70
|
+
/** Everything that differs between two members of this family. */
|
|
71
|
+
export interface RemoteProfileDefinition<TClient extends RemoteProfileClient> {
|
|
72
|
+
/** Registry id — matches `config.backend` ("kilo"). */
|
|
73
|
+
id: BackendId;
|
|
74
|
+
/** Display label for log lines, headers and error text ("Kilo"). */
|
|
75
|
+
label: string;
|
|
76
|
+
/** npm package of the SDK, for the startup log line. */
|
|
77
|
+
sdkPackage: string;
|
|
78
|
+
/** Loopback port the local server listens on by default. */
|
|
79
|
+
defaultPort: number;
|
|
80
|
+
/**
|
|
81
|
+
* Env var that overrides the port, so integration tests can spawn an
|
|
82
|
+
* isolated server alongside a running production Talon that holds the
|
|
83
|
+
* default.
|
|
84
|
+
*/
|
|
85
|
+
portEnv: string;
|
|
86
|
+
/** Delivery contract the system-prompt suffix carries. */
|
|
87
|
+
deliveryContract: DeliveryMode;
|
|
88
|
+
/** Strict SDK client over an already-running server URL. */
|
|
89
|
+
createClient(baseUrl: string): TClient;
|
|
90
|
+
/** Spawn a fresh local server; `close()` runs from `stop()`. */
|
|
91
|
+
createServer(args: {
|
|
92
|
+
hostname: string;
|
|
93
|
+
port: number;
|
|
94
|
+
timeout: number;
|
|
95
|
+
}): Promise<{ url: string; close(): void }>;
|
|
96
|
+
/**
|
|
97
|
+
* Split a stored model-selection string into provider/model ids. The
|
|
98
|
+
* one genuinely behavioural knob: the two upstream routers disagree
|
|
99
|
+
* about what a `provider/model` prefix means.
|
|
100
|
+
*/
|
|
101
|
+
parseModelSelection(value: string): RemoteModelSelection;
|
|
102
|
+
/**
|
|
103
|
+
* Model-picker budget, set by the surface the picker renders through.
|
|
104
|
+
* Discord StringSelectMenu values hold 100 chars of anything; Telegram
|
|
105
|
+
* `callback_data` holds 64 bytes and the keyboard is tight.
|
|
106
|
+
*/
|
|
107
|
+
maxCallbackIdLength: number;
|
|
108
|
+
allowCallbackSeparators: boolean;
|
|
109
|
+
quickPickLimit: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A bound driver: the server bindings, the catalog module, and the
|
|
114
|
+
* registry factory inputs, in one object.
|
|
115
|
+
*/
|
|
116
|
+
export type RemoteProfile<TClient extends RemoteProfileClient> =
|
|
117
|
+
RemoteServerBindings<TClient> &
|
|
118
|
+
RemoteBackendFactoryInputs & {
|
|
119
|
+
/** The bound catalog — cache, resolution, and picker rendering. */
|
|
120
|
+
catalog: RemoteModelCatalogModule;
|
|
121
|
+
/**
|
|
122
|
+
* The knobs this driver was built from. Kept on the result so a
|
|
123
|
+
* live-backend test can rebind the catalog to its own throwaway
|
|
124
|
+
* server without restating the profile's picker budget.
|
|
125
|
+
*/
|
|
126
|
+
definition: RemoteProfileDefinition<TClient>;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export function bindRemoteProfile<TClient extends RemoteProfileClient>(
|
|
130
|
+
definition: RemoteProfileDefinition<TClient>,
|
|
131
|
+
): RemoteProfile<TClient> {
|
|
132
|
+
const { id, label, sdkPackage } = definition;
|
|
133
|
+
|
|
134
|
+
const server = bindRemoteServer<TClient>({
|
|
135
|
+
label,
|
|
136
|
+
defaultPort: definition.defaultPort,
|
|
137
|
+
portEnv: definition.portEnv,
|
|
138
|
+
deliveryContract: definition.deliveryContract,
|
|
139
|
+
createClient: definition.createClient,
|
|
140
|
+
createServer: definition.createServer,
|
|
141
|
+
parseModelSelection: definition.parseModelSelection,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const catalog = createRemoteModelCatalogModule({
|
|
145
|
+
label,
|
|
146
|
+
getClient: () => server.ensureServer(),
|
|
147
|
+
maxCallbackIdLength: definition.maxCallbackIdLength,
|
|
148
|
+
allowCallbackSeparators: definition.allowCallbackSeparators,
|
|
149
|
+
quickPickLimit: definition.quickPickLimit,
|
|
150
|
+
});
|
|
151
|
+
// A stopped server invalidates the catalog it served.
|
|
152
|
+
server.onServerStop(catalog.clearCache);
|
|
153
|
+
|
|
154
|
+
const models = createRemoteModelProvider({
|
|
155
|
+
label,
|
|
156
|
+
getCatalog: (forceRefresh) => catalog.getCatalog(forceRefresh),
|
|
157
|
+
getModelInfo: (modelId) => catalog.getModelInfo(modelId),
|
|
158
|
+
resolveModelInput: (query, cat) => resolveRemoteModelInput(query, cat),
|
|
159
|
+
getSelectionValue: (model, cat) => getRemoteModelSelectionValue(model, cat),
|
|
160
|
+
formatUnavailableModel: (model) => formatRemoteUnavailableModel(model),
|
|
161
|
+
getSettingsPresentation: (activeModel, pickerOptions) =>
|
|
162
|
+
catalog.getSettingsPresentation(activeModel, pickerOptions),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const handleMessage = (params: QueryParams): Promise<QueryResult> =>
|
|
166
|
+
runRemoteChatTurn(
|
|
167
|
+
{
|
|
168
|
+
id,
|
|
169
|
+
label,
|
|
170
|
+
getConfig: server.getConfig,
|
|
171
|
+
ensureServer: server.ensureServer,
|
|
172
|
+
trackActiveTurn: server.trackActiveTurn,
|
|
173
|
+
parseModelSelection: server.parseModelSelection,
|
|
174
|
+
resolveProviderID: server.resolveProviderID,
|
|
175
|
+
ensureSession: server.ensureSession,
|
|
176
|
+
ensureChatMcpServer: server.ensureChatMcpServer,
|
|
177
|
+
ensurePluginMcpServers: server.ensurePluginMcpServers,
|
|
178
|
+
buildToolOverrides: server.buildToolOverrides,
|
|
179
|
+
systemPromptSuffix: server.systemPromptSuffix,
|
|
180
|
+
},
|
|
181
|
+
params,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const runOneShotAgent = (
|
|
185
|
+
params: OneShotAgentParams,
|
|
186
|
+
): Promise<OneShotUsage | void> =>
|
|
187
|
+
runRemoteOneShotAgent(
|
|
188
|
+
{
|
|
189
|
+
label,
|
|
190
|
+
// The one-shot runner has no frontend in hand (heartbeat and
|
|
191
|
+
// dream are cross-surface), so it carries the telegram-shaped
|
|
192
|
+
// suffix — the same one the per-backend runners passed.
|
|
193
|
+
systemPromptSuffix: server.defaultSystemPromptSuffix,
|
|
194
|
+
ensureServer: server.ensureServer,
|
|
195
|
+
parseModelSelection: server.parseModelSelection,
|
|
196
|
+
resolveProviderID: server.resolveProviderID,
|
|
197
|
+
ensureChatMcpServer: server.ensureChatMcpServer,
|
|
198
|
+
ensurePluginMcpServers: server.ensurePluginMcpServers,
|
|
199
|
+
buildToolOverrides: server.buildToolOverrides,
|
|
200
|
+
disconnectChatMcpServer: server.disconnectChatMcpServer,
|
|
201
|
+
errMsg: server.errMsg,
|
|
202
|
+
},
|
|
203
|
+
params,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
...server,
|
|
208
|
+
id,
|
|
209
|
+
label,
|
|
210
|
+
sdkPackage,
|
|
211
|
+
definition,
|
|
212
|
+
catalog,
|
|
213
|
+
models,
|
|
214
|
+
handleMessage,
|
|
215
|
+
runOneShotAgent,
|
|
216
|
+
async getSessionSnapshot(sessionId) {
|
|
217
|
+
if (!sessionId) return undefined;
|
|
218
|
+
const oc = await server.ensureServer();
|
|
219
|
+
return getSessionSnapshot(
|
|
220
|
+
oc as unknown as RemoteSessionClient,
|
|
221
|
+
sessionId,
|
|
222
|
+
);
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote-server profiles — the drivers built from `./bind.ts`.
|
|
3
|
+
*
|
|
4
|
+
* One import for `backend/builtins.ts`, which is the only place that
|
|
5
|
+
* lists them (structure rule 4). Adding a member of this family is
|
|
6
|
+
* adding a profile module here and a line there.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { kiloProfile } from "./kilo.js";
|
|
10
|
+
export { opencodeProfile } from "./opencode.js";
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kilo — a remote-server profile.
|
|
3
|
+
*
|
|
4
|
+
* Kilo is a fork of OpenCode and exposes the same HTTP API, so the whole
|
|
5
|
+
* driver is `bindRemoteProfile` over the constants below: the
|
|
6
|
+
* `@kilocode/sdk` constructors, port 4097, the text-or-tools delivery
|
|
7
|
+
* contract, the `kilo/`-prefix model parser, and the Discord-sized model
|
|
8
|
+
* picker budget.
|
|
9
|
+
*
|
|
10
|
+
* Kilo delivery model: the reply reaches the user either as a `text`
|
|
11
|
+
* part (what most Kilo-routed models emit by default — DeepSeek, GLM,
|
|
12
|
+
* openrouter routes) or through a delivery tool (`end_turn` / `send` /
|
|
13
|
+
* `react`) when reply-to targeting, buttons, photos, or polls are
|
|
14
|
+
* needed. Both routes work; the shared text-or-tools contract documents
|
|
15
|
+
* the choice.
|
|
16
|
+
*
|
|
17
|
+
* Note: the model catalog's internal type names are `Remote*` and its
|
|
18
|
+
* wire shape is OpenCode's — Kilo's provider-bucket API is forked from
|
|
19
|
+
* it, so the names match what the upstream actually emits.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
createKiloClient,
|
|
24
|
+
createKiloServer,
|
|
25
|
+
type KiloClient,
|
|
26
|
+
} from "@kilocode/sdk/v2";
|
|
27
|
+
import type { RemoteModelSelection } from "../server-bindings.js";
|
|
28
|
+
import { bindRemoteProfile, type RemoteProfile } from "./bind.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse the stored model-selection string into a `{providerID?, modelID}`
|
|
32
|
+
* pair.
|
|
33
|
+
*
|
|
34
|
+
* Kilo model ids frequently contain `/` and `:` inside the model.id itself
|
|
35
|
+
* (e.g. `inclusionai/ling-2.6-1t:free`, `deepseek/deepseek-v4-flash:free`).
|
|
36
|
+
* A naive `provider/model` splitter mis-treats those vendor prefixes as
|
|
37
|
+
* the provider, so we generally return the whole string as the model id
|
|
38
|
+
* and let `resolveProviderID` look up the real provider from the live
|
|
39
|
+
* catalog.
|
|
40
|
+
*
|
|
41
|
+
* Exception: if the value starts with the literal `kilo/` prefix
|
|
42
|
+
* (Talon's old hint that "this is a kilo-routed model"), strip it AND
|
|
43
|
+
* pin providerID to `"kilo"`. Otherwise the upstream Kilo router gets
|
|
44
|
+
* `kilo/deepseek/deepseek-v4-flash:free` as the model id and concats
|
|
45
|
+
* its own provider in front, producing
|
|
46
|
+
* `Model not found: opencode/kilo/deepseek/deepseek-v4-flash:free`.
|
|
47
|
+
*/
|
|
48
|
+
function parseStoredKiloModelSelection(value: string): RemoteModelSelection {
|
|
49
|
+
const trimmed = value.trim();
|
|
50
|
+
if (trimmed.startsWith("kilo/")) {
|
|
51
|
+
return {
|
|
52
|
+
providerID: "kilo",
|
|
53
|
+
modelID: trimmed.slice("kilo/".length),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
providerID: undefined,
|
|
58
|
+
modelID: trimmed,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const kiloProfile: RemoteProfile<KiloClient> =
|
|
63
|
+
bindRemoteProfile<KiloClient>({
|
|
64
|
+
id: "kilo",
|
|
65
|
+
label: "Kilo",
|
|
66
|
+
sdkPackage: "@kilocode/sdk",
|
|
67
|
+
defaultPort: 4097,
|
|
68
|
+
portEnv: "KILO_PORT",
|
|
69
|
+
deliveryContract: "text-or-tools",
|
|
70
|
+
createClient: (baseUrl) =>
|
|
71
|
+
createKiloClient({ baseUrl, throwOnError: true }),
|
|
72
|
+
createServer: ({ hostname, port, timeout }) =>
|
|
73
|
+
createKiloServer({ hostname, port, timeout }),
|
|
74
|
+
parseModelSelection: parseStoredKiloModelSelection,
|
|
75
|
+
// Kilo's model picker renders through Discord StringSelectMenus,
|
|
76
|
+
// which allow 25 options and values up to 100 chars with any
|
|
77
|
+
// characters — Kilo ids routinely contain "/" and ":" (e.g.
|
|
78
|
+
// "inclusionai/ling-2.6-1t:free").
|
|
79
|
+
maxCallbackIdLength: 90,
|
|
80
|
+
allowCallbackSeparators: true,
|
|
81
|
+
quickPickLimit: 24,
|
|
82
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode — a remote-server profile.
|
|
3
|
+
*
|
|
4
|
+
* The whole driver is `bindRemoteProfile` over the constants below: the
|
|
5
|
+
* `@opencode-ai/sdk` constructors, port 4096, the text-preferred
|
|
6
|
+
* delivery contract, the fuzzy `provider/model` parser, and the
|
|
7
|
+
* Telegram-sized model picker budget.
|
|
8
|
+
*
|
|
9
|
+
* Text-preferred delivery: plain assistant text is the reply; tools only
|
|
10
|
+
* for genuine side effects. Single-sourced from the shared contract
|
|
11
|
+
* templates (prompts/system/contract-text-preferred.md).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
createOpencodeClient,
|
|
16
|
+
createOpencodeServer,
|
|
17
|
+
type OpencodeClient,
|
|
18
|
+
} from "@opencode-ai/sdk/v2";
|
|
19
|
+
import {
|
|
20
|
+
normalizeModelLookup,
|
|
21
|
+
parseRemoteModelQuery,
|
|
22
|
+
} from "../model-catalog/index.js";
|
|
23
|
+
import type { RemoteModelSelection } from "../server-bindings.js";
|
|
24
|
+
import { bindRemoteProfile, type RemoteProfile } from "./bind.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse the stored model-selection string into a `{providerID?, modelID}`
|
|
28
|
+
* pair. The parser is fuzzy — it tries to extract a provider hint from the
|
|
29
|
+
* prefix while preserving the full model id when ambiguous. See
|
|
30
|
+
* `remote-server/model-catalog/` for the underlying `parseRemoteModelQuery`.
|
|
31
|
+
*/
|
|
32
|
+
function parseStoredOpenCodeModelSelection(
|
|
33
|
+
value: string,
|
|
34
|
+
): RemoteModelSelection {
|
|
35
|
+
const { providerQuery, modelQuery } = parseRemoteModelQuery(value);
|
|
36
|
+
return {
|
|
37
|
+
providerID: providerQuery ? normalizeModelLookup(providerQuery) : undefined,
|
|
38
|
+
modelID: modelQuery,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const opencodeProfile: RemoteProfile<OpencodeClient> =
|
|
43
|
+
bindRemoteProfile<OpencodeClient>({
|
|
44
|
+
id: "opencode",
|
|
45
|
+
label: "OpenCode",
|
|
46
|
+
sdkPackage: "@opencode-ai/sdk",
|
|
47
|
+
defaultPort: 4096,
|
|
48
|
+
portEnv: "OPENCODE_PORT",
|
|
49
|
+
deliveryContract: "text-preferred",
|
|
50
|
+
createClient: (baseUrl) =>
|
|
51
|
+
createOpencodeClient({ baseUrl, throwOnError: true }),
|
|
52
|
+
createServer: ({ hostname, port, timeout }) =>
|
|
53
|
+
createOpencodeServer({ hostname, port, timeout }),
|
|
54
|
+
parseModelSelection: parseStoredOpenCodeModelSelection,
|
|
55
|
+
// OpenCode's model picker renders through Telegram inline keyboards —
|
|
56
|
+
// callback_data caps at 64 bytes and the keyboard is tight, so quick
|
|
57
|
+
// picks stay at 4 and only short separator-free ids are embedded raw.
|
|
58
|
+
maxCallbackIdLength: 48,
|
|
59
|
+
allowCallbackSeparators: false,
|
|
60
|
+
quickPickLimit: 4,
|
|
61
|
+
});
|
|
@@ -14,10 +14,9 @@
|
|
|
14
14
|
* of hand-written pass-through wrappers around the shared helpers. They
|
|
15
15
|
* drifted in the small ways copies do (one documented the port override,
|
|
16
16
|
* the other didn't; one exported `errMsg`, the other aliased it) while
|
|
17
|
-
* doing exactly the same thing.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `vi.mock("../backend/<name>/server.js")` all address.
|
|
17
|
+
* doing exactly the same thing. Those wrapper files are gone:
|
|
18
|
+
* `profiles/bind.ts` calls this once per driver and hands the result
|
|
19
|
+
* straight to `createRemoteBackendFactory`.
|
|
21
20
|
*/
|
|
22
21
|
|
|
23
22
|
import type { TalonConfig } from "../../core/config/index.js";
|
package/src/bootstrap.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
initTriggers,
|
|
34
34
|
resumeAfterRestart as resumeTriggersAfterRestart,
|
|
35
35
|
} from "./core/background/triggers/index.js";
|
|
36
|
+
import { initAgents } from "./core/agents/index.js";
|
|
36
37
|
import { initDream, maybeStartDream } from "./core/background/dream/index.js";
|
|
37
38
|
import { initHeartbeat } from "./core/background/heartbeat/index.js";
|
|
38
39
|
import { log, logWarn, logDebug } from "./util/log.js";
|
|
@@ -464,7 +465,7 @@ export async function initBackendAndDispatcher(
|
|
|
464
465
|
model: config.heartbeatModel ?? config.model ?? null,
|
|
465
466
|
}),
|
|
466
467
|
});
|
|
467
|
-
|
|
468
|
+
initWakeSubsystems(config);
|
|
468
469
|
resumeTriggersAfterRestart().catch((err) =>
|
|
469
470
|
log("triggers", `resumeAfterRestart failed: ${err}`),
|
|
470
471
|
);
|
|
@@ -567,6 +568,19 @@ export async function initBackendAndDispatcher(
|
|
|
567
568
|
return { backend };
|
|
568
569
|
}
|
|
569
570
|
|
|
571
|
+
/**
|
|
572
|
+
* Wire the two subsystems that wake a chat with a synthetic turn: trigger
|
|
573
|
+
* scripts firing, and sub-agents reporting. Same dependency (the dispatcher),
|
|
574
|
+
* same delivery shape, so they are wired together.
|
|
575
|
+
*/
|
|
576
|
+
function initWakeSubsystems(config: TalonConfig): void {
|
|
577
|
+
initTriggers({ execute: dispatcherExecute });
|
|
578
|
+
initAgents({
|
|
579
|
+
execute: dispatcherExecute,
|
|
580
|
+
...(config.agents ? { caps: config.agents } : {}),
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
570
584
|
/**
|
|
571
585
|
* Wire the admin notification seam to the admin's frontend, and start the
|
|
572
586
|
* login-expiry monitor that rides it: the CLIs' "N days to log in again"
|
package/src/cli/chat.ts
CHANGED
|
@@ -47,4 +47,9 @@ export async function startChat(): Promise<void> {
|
|
|
47
47
|
process.exit(0);
|
|
48
48
|
});
|
|
49
49
|
await frontend.start();
|
|
50
|
+
// start() resolves once the prompt is up (the frontend lifecycle
|
|
51
|
+
// contract); readline drives the session from its own callbacks after
|
|
52
|
+
// that, so hold this entry point open until /exit or SIGINT exits the
|
|
53
|
+
// process.
|
|
54
|
+
await new Promise<never>(() => {});
|
|
50
55
|
}
|
package/src/cli/events.ts
CHANGED
|
@@ -35,6 +35,15 @@ function describe(event: TalonEvent): string {
|
|
|
35
35
|
return `chat=${event.chatId} ${event.backendId}/${event.model} (${event.source})`;
|
|
36
36
|
case "turn.completed":
|
|
37
37
|
return `chat=${event.chatId} ${event.durationMs}ms in=${event.inputTokens} out=${event.outputTokens}`;
|
|
38
|
+
case "agent.spawned":
|
|
39
|
+
return (
|
|
40
|
+
`${event.agentId} "${event.label}" ${event.backendId}/${event.model} ` +
|
|
41
|
+
`depth=${event.depth} parent=${event.parent}`
|
|
42
|
+
);
|
|
43
|
+
case "agent.settled":
|
|
44
|
+
return `${event.agentId} "${event.label}" → ${event.state} (${event.durationMs}ms)`;
|
|
45
|
+
case "agent.message":
|
|
46
|
+
return `${event.from} → ${event.to} (${event.kind})`;
|
|
38
47
|
}
|
|
39
48
|
}
|
|
40
49
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agent context vocabulary — the one place that knows what an
|
|
3
|
+
* `agent:<id>` context label looks like.
|
|
4
|
+
*
|
|
5
|
+
* A one-shot run carries a `contextLabel` (`OneShotAgentParams`) that every
|
|
6
|
+
* layer downstream keys on: the backend picks the MCP server set from it, the
|
|
7
|
+
* MCP hub binds a tool session to it, and the bridge sends it back to the
|
|
8
|
+
* gateway as `_chatId`. A sub-agent's label is `agent:<id>`, which is how the
|
|
9
|
+
* gateway recognises a tool call as coming from that agent rather than from a
|
|
10
|
+
* chat.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately a dependency-free leaf: `backend/claude-sdk` and
|
|
13
|
+
* `core/engine/gateway` both import it, and routing either through
|
|
14
|
+
* `core/agents/index.ts` (which pulls in the runner, and with it the backend
|
|
15
|
+
* pool) would close an import cycle.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Prefix of every sub-agent context label / chat key. */
|
|
19
|
+
export const AGENT_CONTEXT_PREFIX = "agent:";
|
|
20
|
+
|
|
21
|
+
/** The context label a sub-agent's one-shot run (and its tools) is bound to. */
|
|
22
|
+
export function agentContextLabel(agentId: string): string {
|
|
23
|
+
return `${AGENT_CONTEXT_PREFIX}${agentId}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The agent id inside a context label, or null when it isn't one. */
|
|
27
|
+
export function agentIdFromContextLabel(label: string): string | null {
|
|
28
|
+
if (!label.startsWith(AGENT_CONTEXT_PREFIX)) return null;
|
|
29
|
+
const id = label.slice(AGENT_CONTEXT_PREFIX.length);
|
|
30
|
+
return id.length > 0 ? id : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether a context label denotes a background run that gets the full
|
|
35
|
+
* cross-surface tool set: frontend tools (outbound with an explicit
|
|
36
|
+
* `chat_id`) plus every loaded plugin.
|
|
37
|
+
*
|
|
38
|
+
* Two labels qualify: `heartbeat` (also reused by isolated cron/trigger jobs,
|
|
39
|
+
* see `background/cron/job-prompt.ts`) and any `agent:<id>` sub-agent run.
|
|
40
|
+
* `dream` deliberately does not — it is a memory-consolidation pass with no
|
|
41
|
+
* business messaging anyone.
|
|
42
|
+
*/
|
|
43
|
+
export function isBackgroundToolContext(contextLabel: string): boolean {
|
|
44
|
+
return (
|
|
45
|
+
contextLabel === "heartbeat" ||
|
|
46
|
+
agentIdFromContextLabel(contextLabel) !== null
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivery — moving text between a sub-agent and its parent, both ways.
|
|
3
|
+
*
|
|
4
|
+
* Downward (`deliverToAgent`) is a mailbox push the agent drains at its own
|
|
5
|
+
* milestones. Upward — a report or an interim note — has two parents, two
|
|
6
|
+
* channels, one shape:
|
|
7
|
+
*
|
|
8
|
+
* - **A chat** is woken with a synthetic turn (`source: "agent"`), exactly
|
|
9
|
+
* as a trigger fires one. The turn resumes the chat's own session, so
|
|
10
|
+
* the model reads the report with full conversational context and
|
|
11
|
+
* decides for itself whether the user hears about it.
|
|
12
|
+
* - **An agent** gets the text pushed into its mailbox, which it drains
|
|
13
|
+
* with `check_inbox`. A parent that has already settled has nowhere to
|
|
14
|
+
* put it: the message is logged and dropped, because reviving a
|
|
15
|
+
* finished run to hear late news is worse than losing the news.
|
|
16
|
+
*
|
|
17
|
+
* No batching: several settlements arriving while a chat is busy become
|
|
18
|
+
* several queued turns, and the weaver serialises per chat. That is the
|
|
19
|
+
* behaviour triggers already have, and the model handles it fine.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { execute as dispatcherExecute } from "../engine/dispatcher.js";
|
|
23
|
+
import { log, logWarn, logError } from "../../util/log.js";
|
|
24
|
+
import { bus } from "../bus/index.js";
|
|
25
|
+
import { agentRegistry } from "./registry.js";
|
|
26
|
+
import { buildMessagePrompt, buildSettlementPrompt } from "./prompt.js";
|
|
27
|
+
import type { AgentMessage, AgentParent, AgentRecord } from "./types.js";
|
|
28
|
+
|
|
29
|
+
/** Injected at startup so this module knows nothing about the dispatcher. */
|
|
30
|
+
export type AgentDeliveryDeps = {
|
|
31
|
+
/** Wakes a chat with a synthetic turn. */
|
|
32
|
+
execute: typeof dispatcherExecute;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Reassignable holder so a re-init (or a test) can swap the deps. */
|
|
36
|
+
const deliveryDeps: { deps: AgentDeliveryDeps | null } = { deps: null };
|
|
37
|
+
|
|
38
|
+
/** Wire the delivery path. Called once from the composition root. */
|
|
39
|
+
export function initAgentDelivery(deps: AgentDeliveryDeps): void {
|
|
40
|
+
deliveryDeps.deps = deps;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Sender/recipient key of a parent, for the `agent.message` event. */
|
|
44
|
+
function parentKey(parent: AgentParent): string {
|
|
45
|
+
return parent.kind === "chat" ? parent.chatId : parent.agentId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Wake a chat with a synthetic agent turn. */
|
|
49
|
+
async function wakeChat(
|
|
50
|
+
chatId: string,
|
|
51
|
+
numericChatId: number,
|
|
52
|
+
prompt: string,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
const deps = deliveryDeps.deps;
|
|
55
|
+
if (!deps) {
|
|
56
|
+
logWarn(
|
|
57
|
+
"agents",
|
|
58
|
+
`delivery not initialised — dropped a report for ${chatId}`,
|
|
59
|
+
);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
await deps.execute({
|
|
64
|
+
chatId,
|
|
65
|
+
numericChatId,
|
|
66
|
+
prompt,
|
|
67
|
+
senderName: "Agent",
|
|
68
|
+
isGroup: false,
|
|
69
|
+
source: "agent",
|
|
70
|
+
});
|
|
71
|
+
} catch (err) {
|
|
72
|
+
logError("agents", `wake dispatch failed for chat ${chatId}`, err);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Push into a live agent's mailbox, or say why it could not be delivered. */
|
|
77
|
+
function pushToAgent(
|
|
78
|
+
parentAgentId: string,
|
|
79
|
+
message: AgentMessage,
|
|
80
|
+
what: string,
|
|
81
|
+
): void {
|
|
82
|
+
if (!agentRegistry.isLive(parentAgentId)) {
|
|
83
|
+
logWarn(
|
|
84
|
+
"agents",
|
|
85
|
+
`dropped ${what} from ${message.from}: parent ${parentAgentId} has already settled`,
|
|
86
|
+
);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (!agentRegistry.push(parentAgentId, message)) {
|
|
90
|
+
logWarn(
|
|
91
|
+
"agents",
|
|
92
|
+
`dropped ${what} from ${message.from}: parent ${parentAgentId}'s inbox is full`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Send an instruction down to a live agent. Returns false when the agent is
|
|
99
|
+
* gone or its mailbox is full — the caller turns that into a tool error, so
|
|
100
|
+
* a parent always learns that its instruction did not land.
|
|
101
|
+
*/
|
|
102
|
+
export function deliverToAgent(
|
|
103
|
+
from: string,
|
|
104
|
+
agentId: string,
|
|
105
|
+
text: string,
|
|
106
|
+
): boolean {
|
|
107
|
+
if (!agentRegistry.isLive(agentId)) return false;
|
|
108
|
+
if (!agentRegistry.push(agentId, { from, text, at: Date.now() }))
|
|
109
|
+
return false;
|
|
110
|
+
bus.publish({
|
|
111
|
+
type: "agent.message",
|
|
112
|
+
from,
|
|
113
|
+
to: agentId,
|
|
114
|
+
kind: "message",
|
|
115
|
+
});
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Deliver a settled agent's report to its parent. */
|
|
120
|
+
export async function deliverSettlement(record: AgentRecord): Promise<void> {
|
|
121
|
+
const prompt = buildSettlementPrompt(record);
|
|
122
|
+
bus.publish({
|
|
123
|
+
type: "agent.message",
|
|
124
|
+
from: record.id,
|
|
125
|
+
to: parentKey(record.parent),
|
|
126
|
+
kind: "result",
|
|
127
|
+
});
|
|
128
|
+
if (record.parent.kind === "chat") {
|
|
129
|
+
log(
|
|
130
|
+
"agents",
|
|
131
|
+
`${record.id} "${record.label}" ${record.state} — waking chat ${record.parent.chatId}`,
|
|
132
|
+
);
|
|
133
|
+
await wakeChat(record.parent.chatId, record.parent.numericChatId, prompt);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
pushToAgent(
|
|
137
|
+
record.parent.agentId,
|
|
138
|
+
{ from: record.id, text: prompt, at: Date.now() },
|
|
139
|
+
"a report",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Deliver an interim `message_parent` note. */
|
|
144
|
+
export async function deliverMessage(
|
|
145
|
+
record: AgentRecord,
|
|
146
|
+
text: string,
|
|
147
|
+
): Promise<void> {
|
|
148
|
+
bus.publish({
|
|
149
|
+
type: "agent.message",
|
|
150
|
+
from: record.id,
|
|
151
|
+
to: parentKey(record.parent),
|
|
152
|
+
kind: "message",
|
|
153
|
+
});
|
|
154
|
+
if (record.parent.kind === "chat") {
|
|
155
|
+
await wakeChat(
|
|
156
|
+
record.parent.chatId,
|
|
157
|
+
record.parent.numericChatId,
|
|
158
|
+
buildMessagePrompt(record, text),
|
|
159
|
+
);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
pushToAgent(
|
|
163
|
+
record.parent.agentId,
|
|
164
|
+
{ from: record.id, text: buildMessagePrompt(record, text), at: Date.now() },
|
|
165
|
+
"a message",
|
|
166
|
+
);
|
|
167
|
+
}
|