talon-agent 3.33.4 → 3.34.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/src/app.ts +16 -8
- package/src/backend/claude-sdk/handler.ts +428 -415
- package/src/backend/claude-sdk/stream.ts +76 -53
- package/src/backend/codex/handler/message.ts +275 -366
- package/src/backend/codex/handler/rollout-accounting.ts +137 -0
- package/src/backend/openai-agents/handler/message.ts +282 -356
- package/src/backend/remote-server/chat-turn.ts +66 -122
- package/src/backend/remote-server/index.ts +4 -0
- package/src/backend/remote-server/mcp.ts +73 -9
- package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
- package/src/backend/remote-server/sessions.ts +2 -2
- package/src/backend/remote-server/turn.ts +58 -55
- package/src/backend/shared/cache-telemetry.ts +17 -1
- package/src/backend/shared/handler-to-events.ts +11 -16
- package/src/backend/shared/index.ts +14 -15
- package/src/backend/shared/result-events.ts +30 -0
- package/src/backend/shared/turn-phases.ts +277 -0
- package/src/bootstrap.ts +120 -79
- package/src/cli/setup.ts +375 -349
- package/src/core/background/cron-spec.ts +273 -0
- package/src/core/background/heartbeat/agent.ts +167 -104
- package/src/core/background/triggers/exit.ts +19 -2
- package/src/core/background/triggers/resume.ts +14 -7
- package/src/core/background/triggers/state.ts +9 -0
- package/src/core/engine/backend-controller/pool.ts +2 -0
- package/src/core/engine/backend-controller/state.ts +25 -12
- package/src/core/engine/gateway-actions/cron.ts +28 -292
- package/src/core/engine/gateway-routes.ts +239 -0
- package/src/core/engine/gateway.ts +66 -238
- package/src/core/mcp-hub/child-transport.ts +215 -0
- package/src/core/mcp-hub/children.ts +72 -13
- package/src/core/mcp-hub/index.ts +32 -13
- package/src/core/models/active-model.ts +29 -6
- package/src/core/vfs/mounts/files.ts +128 -115
- package/src/core/weaver/shuttle.ts +33 -2
- package/src/core/weaver/weaver.ts +37 -6
- package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
- package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
- package/src/frontend/discord/callbacks/components/effort.ts +72 -0
- package/src/frontend/discord/callbacks/components/index.ts +120 -0
- package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
- package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
- package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
- package/src/frontend/discord/callbacks/components/model.ts +33 -0
- package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
- package/src/frontend/discord/callbacks/components/settings.ts +243 -0
- package/src/frontend/discord/callbacks/components/types.ts +34 -0
- package/src/frontend/discord/callbacks/index.ts +4 -4
- package/src/frontend/discord/connection.ts +36 -0
- package/src/frontend/discord/diagnostics.ts +62 -0
- package/src/frontend/discord/guild-policy.ts +89 -0
- package/src/frontend/discord/index.ts +44 -305
- package/src/frontend/discord/outbound.ts +61 -0
- package/src/frontend/discord/ready.ts +73 -0
- package/src/frontend/discord/runtime.ts +55 -0
- package/src/frontend/native/chat-lifecycle.ts +39 -0
- package/src/frontend/native/chat-wire.ts +69 -0
- package/src/frontend/native/context.ts +106 -0
- package/src/frontend/native/control.ts +78 -0
- package/src/frontend/native/emit.ts +167 -0
- package/src/frontend/native/empty-chat-sweep.ts +50 -0
- package/src/frontend/native/handlers.ts +121 -0
- package/src/frontend/native/history.ts +101 -0
- package/src/frontend/native/index.ts +90 -1293
- package/src/frontend/native/media.ts +43 -0
- package/src/frontend/native/models.ts +221 -0
- package/src/frontend/native/queue.ts +47 -0
- package/src/frontend/native/reset.ts +50 -0
- package/src/frontend/native/routes/chats.ts +115 -0
- package/src/frontend/native/routes/daemon.ts +70 -0
- package/src/frontend/native/routes/host.ts +151 -0
- package/src/frontend/native/routes/index.ts +22 -0
- package/src/frontend/native/routes/mesh.ts +72 -0
- package/src/frontend/native/routes/models.ts +54 -0
- package/src/frontend/native/routes/params.ts +29 -0
- package/src/frontend/native/routes/pre-auth.ts +94 -0
- package/src/frontend/native/routes/table.ts +92 -0
- package/src/frontend/native/runtime.ts +109 -0
- package/src/frontend/native/server.ts +30 -555
- package/src/frontend/native/status.ts +26 -0
- package/src/frontend/native/tool-result.ts +48 -0
- package/src/frontend/native/turn.ts +341 -0
- package/src/frontend/telegram/admin.ts +75 -52
- package/src/frontend/whatsapp/access.ts +67 -0
- package/src/frontend/whatsapp/connection.ts +280 -0
- package/src/frontend/whatsapp/inbound.ts +327 -0
- package/src/frontend/whatsapp/index.ts +28 -599
- package/src/frontend/whatsapp/runtime.ts +74 -0
- package/src/storage/metrics.ts +18 -0
- package/src/storage/session-record.ts +29 -0
- package/src/storage/sessions.ts +24 -0
- package/src/storage/trigger-store.ts +8 -0
- package/src/util/boot-timer.ts +31 -0
- package/src/util/concurrency.ts +28 -0
- package/src/util/watchdog.ts +32 -7
- package/src/frontend/discord/callbacks/components.ts +0 -793
- package/src/frontend/discord/callbacks/shared.ts +0 -22
|
@@ -20,134 +20,147 @@ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
|
20
20
|
import type { VfsMount, VfsResult, VfsStat } from "../types.js";
|
|
21
21
|
import { VFS_MAX_READ_BYTES, vfsError, vfsOk } from "../types.js";
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
type FileMountOptions = {
|
|
24
24
|
root: string;
|
|
25
25
|
description: string;
|
|
26
26
|
writable: boolean;
|
|
27
|
-
}
|
|
28
|
-
const root = resolve(options.root);
|
|
27
|
+
};
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
offset === ".." ||
|
|
37
|
-
isAbsolute(offset)
|
|
38
|
-
) {
|
|
39
|
-
return undefined;
|
|
40
|
-
}
|
|
41
|
-
return abs;
|
|
29
|
+
/** Absolute path for `rel` under `root`, or undefined when it would escape. */
|
|
30
|
+
function realPath(root: string, rel: string): string | undefined {
|
|
31
|
+
const abs = resolve(root, ...rel.split("/"));
|
|
32
|
+
const offset = relative(root, abs);
|
|
33
|
+
if (offset.startsWith(`..${sep}`) || offset === ".." || isAbsolute(offset)) {
|
|
34
|
+
return undefined;
|
|
42
35
|
}
|
|
36
|
+
return abs;
|
|
37
|
+
}
|
|
43
38
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
39
|
+
function statAt(
|
|
40
|
+
rel: string,
|
|
41
|
+
abs: string,
|
|
42
|
+
writable: boolean,
|
|
43
|
+
): VfsResult<VfsStat> {
|
|
44
|
+
try {
|
|
45
|
+
const stats = statSync(abs);
|
|
46
|
+
const name = rel === "" ? "" : rel.split("/").at(-1)!;
|
|
47
|
+
return vfsOk({
|
|
48
|
+
path: rel,
|
|
49
|
+
name,
|
|
50
|
+
kind: stats.isDirectory() ? ("dir" as const) : ("file" as const),
|
|
51
|
+
...(stats.isFile() ? { size: stats.size } : {}),
|
|
52
|
+
modifiedAt: Math.floor(stats.mtimeMs),
|
|
53
|
+
writable,
|
|
54
|
+
osPath: abs,
|
|
55
|
+
});
|
|
56
|
+
} catch {
|
|
57
|
+
return vfsError("not-found");
|
|
60
58
|
}
|
|
59
|
+
}
|
|
61
60
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
function statMount(
|
|
62
|
+
root: string,
|
|
63
|
+
writable: boolean,
|
|
64
|
+
rel: string,
|
|
65
|
+
): VfsResult<VfsStat> {
|
|
66
|
+
const abs = realPath(root, rel);
|
|
67
|
+
if (!abs) return vfsError("invalid-path");
|
|
68
|
+
if (rel === "" && !existsSync(abs)) {
|
|
69
|
+
return vfsOk({ path: "", name: "", kind: "dir", writable, osPath: abs });
|
|
70
|
+
}
|
|
71
|
+
return statAt(rel, abs, writable);
|
|
72
|
+
}
|
|
66
73
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
function listMount(
|
|
75
|
+
root: string,
|
|
76
|
+
writable: boolean,
|
|
77
|
+
rel: string,
|
|
78
|
+
): VfsResult<VfsStat[]> {
|
|
79
|
+
const abs = realPath(root, rel);
|
|
80
|
+
if (!abs) return vfsError("invalid-path");
|
|
81
|
+
if (rel === "" && !existsSync(abs)) return vfsOk([]);
|
|
82
|
+
let stats;
|
|
83
|
+
try {
|
|
84
|
+
stats = statSync(abs);
|
|
85
|
+
} catch {
|
|
86
|
+
return vfsError("not-found");
|
|
87
|
+
}
|
|
88
|
+
if (!stats.isDirectory()) return vfsError("not-a-directory");
|
|
89
|
+
const entries = readdirSync(abs, { withFileTypes: true })
|
|
90
|
+
.map((entry) => {
|
|
91
|
+
const childRel = rel === "" ? entry.name : `${rel}/${entry.name}`;
|
|
92
|
+
const child = statAt(childRel, resolve(abs, entry.name), writable);
|
|
93
|
+
return child.ok ? child.value : undefined;
|
|
94
|
+
})
|
|
95
|
+
.filter((entry): entry is VfsStat => entry !== undefined)
|
|
96
|
+
.sort(
|
|
97
|
+
(a, b) =>
|
|
98
|
+
Number(a.kind === "file") - Number(b.kind === "file") ||
|
|
99
|
+
a.name.localeCompare(b.name),
|
|
100
|
+
);
|
|
101
|
+
return vfsOk(entries);
|
|
102
|
+
}
|
|
81
103
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
);
|
|
105
|
-
return vfsOk(entries);
|
|
106
|
-
},
|
|
104
|
+
function readMount(root: string, rel: string): VfsResult<string> {
|
|
105
|
+
const abs = realPath(root, rel);
|
|
106
|
+
if (!abs) return vfsError("invalid-path");
|
|
107
|
+
let stats;
|
|
108
|
+
try {
|
|
109
|
+
stats = statSync(abs);
|
|
110
|
+
} catch {
|
|
111
|
+
return vfsError("not-found");
|
|
112
|
+
}
|
|
113
|
+
if (stats.isDirectory()) return vfsError("is-a-directory");
|
|
114
|
+
if (stats.size > VFS_MAX_READ_BYTES) {
|
|
115
|
+
return vfsError(
|
|
116
|
+
"too-large",
|
|
117
|
+
`${stats.size} bytes (cap ${VFS_MAX_READ_BYTES}) — on disk at ${abs}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const buffer = readFileSync(abs);
|
|
121
|
+
if (buffer.includes(0)) {
|
|
122
|
+
return vfsError("binary-file", "Content contains null bytes");
|
|
123
|
+
}
|
|
124
|
+
return vfsOk(buffer.toString("utf-8"));
|
|
125
|
+
}
|
|
107
126
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
function writeMount(
|
|
128
|
+
root: string,
|
|
129
|
+
rel: string,
|
|
130
|
+
content: string,
|
|
131
|
+
): VfsResult<VfsStat> {
|
|
132
|
+
if (rel === "") return vfsError("is-a-directory");
|
|
133
|
+
const abs = realPath(root, rel);
|
|
134
|
+
if (!abs) return vfsError("invalid-path");
|
|
135
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) {
|
|
136
|
+
return vfsError("is-a-directory");
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
140
|
+
writeFileSync(abs, content, "utf-8");
|
|
141
|
+
} catch (err) {
|
|
142
|
+
return vfsError(
|
|
143
|
+
"io-error",
|
|
144
|
+
err instanceof Error ? err.message : String(err),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return statAt(rel, abs, true);
|
|
148
|
+
}
|
|
130
149
|
|
|
131
|
-
|
|
150
|
+
export function createFileMount(options: FileMountOptions): VfsMount {
|
|
151
|
+
const root = resolve(options.root);
|
|
152
|
+
const { writable } = options;
|
|
153
|
+
return {
|
|
154
|
+
description: options.description,
|
|
155
|
+
writable,
|
|
156
|
+
osRoot: root,
|
|
157
|
+
stat: (rel) => statMount(root, writable, rel),
|
|
158
|
+
list: (rel) => listMount(root, writable, rel),
|
|
159
|
+
read: (rel) => readMount(root, rel),
|
|
160
|
+
...(writable
|
|
132
161
|
? {
|
|
133
|
-
write(rel: string, content: string)
|
|
134
|
-
|
|
135
|
-
const abs = realPath(rel);
|
|
136
|
-
if (!abs) return vfsError("invalid-path");
|
|
137
|
-
if (existsSync(abs) && statSync(abs).isDirectory()) {
|
|
138
|
-
return vfsError("is-a-directory");
|
|
139
|
-
}
|
|
140
|
-
try {
|
|
141
|
-
mkdirSync(dirname(abs), { recursive: true });
|
|
142
|
-
writeFileSync(abs, content, "utf-8");
|
|
143
|
-
} catch (err) {
|
|
144
|
-
return vfsError(
|
|
145
|
-
"io-error",
|
|
146
|
-
err instanceof Error ? err.message : String(err),
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
return statAt(rel, abs);
|
|
150
|
-
},
|
|
162
|
+
write: (rel: string, content: string) =>
|
|
163
|
+
writeMount(root, rel, content),
|
|
151
164
|
}
|
|
152
165
|
: {}),
|
|
153
166
|
};
|
|
@@ -26,9 +26,22 @@ import {
|
|
|
26
26
|
type AgentEvent,
|
|
27
27
|
type AgentResult,
|
|
28
28
|
} from "../agent-runtime/events.js";
|
|
29
|
+
import { recordTurnActivity } from "../../util/watchdog.js";
|
|
29
30
|
|
|
30
31
|
export type EventSink = (event: AgentEvent) => void | Promise<void>;
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Wall-clock the shuttle observes while carrying one stream: when the
|
|
35
|
+
* first event arrived (`undefined` if the stream ended empty) and how many
|
|
36
|
+
* ms were spent awaiting the frontend's sink in total.
|
|
37
|
+
*/
|
|
38
|
+
export type ShuttleTiming = {
|
|
39
|
+
firstEventAt?: number;
|
|
40
|
+
deliveryMs: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const startShuttleTiming = (): ShuttleTiming => ({ deliveryMs: 0 });
|
|
44
|
+
|
|
32
45
|
/**
|
|
33
46
|
* Pump the stream to completion. Returns the `completed` event's
|
|
34
47
|
* result (if the backend emitted one); throws `AgentRunError` when the
|
|
@@ -37,16 +50,34 @@ export type EventSink = (event: AgentEvent) => void | Promise<void>;
|
|
|
37
50
|
export async function carryTurnEvents(
|
|
38
51
|
stream: AsyncIterable<AgentEvent>,
|
|
39
52
|
onEvent?: EventSink,
|
|
53
|
+
timing?: ShuttleTiming,
|
|
40
54
|
): Promise<AgentResult | undefined> {
|
|
41
55
|
let agentResult: AgentResult | undefined;
|
|
56
|
+
const sink: EventSink | undefined =
|
|
57
|
+
onEvent && timing
|
|
58
|
+
? async (event) => {
|
|
59
|
+
const t0 = Date.now();
|
|
60
|
+
try {
|
|
61
|
+
await onEvent(event);
|
|
62
|
+
} finally {
|
|
63
|
+
timing.deliveryMs += Date.now() - t0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
: onEvent;
|
|
42
67
|
for await (const event of stream) {
|
|
68
|
+
// Every backend's events cross this loop exactly once, so this is the
|
|
69
|
+
// one place the watchdog learns a turn is alive (vs. wedged).
|
|
70
|
+
recordTurnActivity();
|
|
71
|
+
if (timing && timing.firstEventAt === undefined) {
|
|
72
|
+
timing.firstEventAt = Date.now();
|
|
73
|
+
}
|
|
43
74
|
if (event.type === "completed") {
|
|
44
75
|
agentResult = event.result;
|
|
45
76
|
}
|
|
46
77
|
|
|
47
78
|
if (event.type === "assistant_message" && event.deliveryAck) {
|
|
48
79
|
try {
|
|
49
|
-
await
|
|
80
|
+
await sink?.(event);
|
|
50
81
|
event.deliveryAck.resolve();
|
|
51
82
|
} catch (err) {
|
|
52
83
|
event.deliveryAck.reject(err);
|
|
@@ -54,7 +85,7 @@ export async function carryTurnEvents(
|
|
|
54
85
|
continue;
|
|
55
86
|
}
|
|
56
87
|
|
|
57
|
-
await
|
|
88
|
+
await sink?.(event);
|
|
58
89
|
if (event.type === "error") {
|
|
59
90
|
throw new AgentRunError(event.error);
|
|
60
91
|
}
|
|
@@ -24,9 +24,11 @@ import type { ContextManager, ExecuteParams, ExecuteResult } from "../types.js";
|
|
|
24
24
|
import { bus } from "../bus/index.js";
|
|
25
25
|
import { taskTable, type TaskHandle } from "../tasks/index.js";
|
|
26
26
|
import { log, logDebug, logWarn } from "../../util/log.js";
|
|
27
|
+
import { recordSessionTurnPhases } from "../../storage/sessions.js";
|
|
28
|
+
import type { TurnPhase } from "../../storage/session-record.js";
|
|
27
29
|
import { TalonError } from "../errors.js";
|
|
28
30
|
import { Loom } from "./loom.js";
|
|
29
|
-
import { carryTurnEvents } from "./shuttle.js";
|
|
31
|
+
import { carryTurnEvents, startShuttleTiming } from "./shuttle.js";
|
|
30
32
|
import type { Thread, ThreadSnapshot } from "./thread.js";
|
|
31
33
|
import { startTypingLoop } from "./typing-loop.js";
|
|
32
34
|
import { resolveWarp } from "./warp-resolver.js";
|
|
@@ -72,7 +74,7 @@ export class Weaver {
|
|
|
72
74
|
// interrupt the same chat's currently running one.
|
|
73
75
|
const chat = this.deps.getBackend(params.chatId).chat;
|
|
74
76
|
const interrupt = chat?.interruptChatTurn?.bind(chat);
|
|
75
|
-
const lifecycle = { started: false, killed: false };
|
|
77
|
+
const lifecycle = { started: false, killed: false, enqueuedAt: Date.now() };
|
|
76
78
|
// Registered before enqueueing so a turn waiting in its chat's FIFO is
|
|
77
79
|
// visible as `queued` in the task table, not invisible until it runs.
|
|
78
80
|
const task = taskTable.enqueue({
|
|
@@ -109,7 +111,7 @@ export class Weaver {
|
|
|
109
111
|
thread: Thread,
|
|
110
112
|
params: ExecuteParams,
|
|
111
113
|
task: TaskHandle,
|
|
112
|
-
lifecycle: { started: boolean; killed: boolean },
|
|
114
|
+
lifecycle: { started: boolean; killed: boolean; enqueuedAt: number },
|
|
113
115
|
): Promise<ExecuteResult> {
|
|
114
116
|
if (lifecycle.killed) {
|
|
115
117
|
// Killed while queued — the turn never reaches the backend. The
|
|
@@ -122,7 +124,9 @@ export class Weaver {
|
|
|
122
124
|
this.activeCount++;
|
|
123
125
|
task.start();
|
|
124
126
|
try {
|
|
125
|
-
const result = await this.executeInner(thread, params, task
|
|
127
|
+
const result = await this.executeInner(thread, params, task, {
|
|
128
|
+
queueWait: Date.now() - lifecycle.enqueuedAt,
|
|
129
|
+
});
|
|
126
130
|
const usage = {
|
|
127
131
|
inputTokens: result.inputTokens,
|
|
128
132
|
outputTokens: result.outputTokens,
|
|
@@ -162,17 +166,20 @@ export class Weaver {
|
|
|
162
166
|
thread: Thread,
|
|
163
167
|
params: ExecuteParams,
|
|
164
168
|
task: TaskHandle,
|
|
169
|
+
phases: Partial<Record<TurnPhase, number>>,
|
|
165
170
|
): Promise<ExecuteResult> {
|
|
166
171
|
const { context } = this.deps;
|
|
167
172
|
const backend = this.deps.getBackend(params.chatId);
|
|
168
173
|
const reqId = randomBytes(4).toString("hex");
|
|
169
174
|
|
|
175
|
+
const warpStartedAt = Date.now();
|
|
170
176
|
const warp = await resolveWarp(this.deps, {
|
|
171
177
|
chatId: params.chatId,
|
|
172
178
|
modelOverride: params.modelOverride,
|
|
173
179
|
source: params.source,
|
|
174
180
|
reqId,
|
|
175
181
|
});
|
|
182
|
+
phases.warpResolve = Date.now() - warpStartedAt;
|
|
176
183
|
if (!warp.ok) {
|
|
177
184
|
// Refusals are delivered through the same event sink the backend
|
|
178
185
|
// would use for output (as an `assistant_message` event, so the
|
|
@@ -246,7 +253,15 @@ export class Weaver {
|
|
|
246
253
|
isGroup: params.isGroup,
|
|
247
254
|
messageId: params.messageId,
|
|
248
255
|
});
|
|
249
|
-
const
|
|
256
|
+
const timing = startShuttleTiming();
|
|
257
|
+
const streamStartedAt = Date.now();
|
|
258
|
+
const agentResult = await carryTurnEvents(stream, params.onEvent, timing);
|
|
259
|
+
phases.stream = Date.now() - streamStartedAt;
|
|
260
|
+
phases.delivery = timing.deliveryMs;
|
|
261
|
+
if (timing.firstEventAt !== undefined) {
|
|
262
|
+
phases.firstToken = timing.firstEventAt - streamStartedAt;
|
|
263
|
+
}
|
|
264
|
+
recordSessionTurnPhases(params.chatId, phases);
|
|
250
265
|
|
|
251
266
|
// Completion signal — pulse (and any other liveness subscriber) keys
|
|
252
267
|
// off this. Failures throw past it; refusals never get this far.
|
|
@@ -261,7 +276,8 @@ export class Weaver {
|
|
|
261
276
|
logDebug(
|
|
262
277
|
"dispatcher",
|
|
263
278
|
`[${reqId}] completed in ${agentResult?.durationMs ?? 0}ms ` +
|
|
264
|
-
`(in=${agentResult?.usage.inputTokens ?? 0} out=${agentResult?.usage.outputTokens ?? 0})
|
|
279
|
+
`(in=${agentResult?.usage.inputTokens ?? 0} out=${agentResult?.usage.outputTokens ?? 0}) ` +
|
|
280
|
+
formatPhases(phases),
|
|
265
281
|
);
|
|
266
282
|
|
|
267
283
|
return this.toExecuteResult(agentResult, params);
|
|
@@ -305,6 +321,21 @@ export class Weaver {
|
|
|
305
321
|
}
|
|
306
322
|
}
|
|
307
323
|
|
|
324
|
+
/** `queue=12ms warp=3ms ttft=1840ms stream=6200ms delivery=310ms` */
|
|
325
|
+
function formatPhases(phases: Partial<Record<TurnPhase, number>>): string {
|
|
326
|
+
const labels: Record<TurnPhase, string> = {
|
|
327
|
+
queueWait: "queue",
|
|
328
|
+
warpResolve: "warp",
|
|
329
|
+
firstToken: "ttft",
|
|
330
|
+
stream: "stream",
|
|
331
|
+
delivery: "delivery",
|
|
332
|
+
};
|
|
333
|
+
return (Object.keys(labels) as TurnPhase[])
|
|
334
|
+
.filter((phase) => phases[phase] !== undefined)
|
|
335
|
+
.map((phase) => `${labels[phase]}=${phases[phase]}ms`)
|
|
336
|
+
.join(" ");
|
|
337
|
+
}
|
|
338
|
+
|
|
308
339
|
let weaver: Weaver | null = null;
|
|
309
340
|
|
|
310
341
|
export function initWeaver(deps: WeaverDeps): Weaver {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ai:*` — AI-generated buttons (send_message_with_buttons), forwarded to
|
|
3
|
+
* the agent verbatim with the routing prefix stripped.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execute } from "../../../../core/engine/dispatcher.js";
|
|
7
|
+
import { appendDailyLog } from "../../../../storage/daily-log.js";
|
|
8
|
+
import { logError } from "../../../../util/log.js";
|
|
9
|
+
import {
|
|
10
|
+
suppressMentions,
|
|
11
|
+
splitMessage,
|
|
12
|
+
DISCORD_MAX_TEXT,
|
|
13
|
+
} from "../../formatting.js";
|
|
14
|
+
import type { ComponentContext, ComponentInteraction } from "./types.js";
|
|
15
|
+
|
|
16
|
+
export async function forwardToAgent(
|
|
17
|
+
interaction: ComponentInteraction,
|
|
18
|
+
{ chatId, numericChatId }: ComponentContext,
|
|
19
|
+
): Promise<boolean> {
|
|
20
|
+
// Acknowledge so Discord doesn't show "interaction failed"
|
|
21
|
+
try {
|
|
22
|
+
await interaction.deferUpdate();
|
|
23
|
+
} catch {
|
|
24
|
+
/* might already be acked */
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sender =
|
|
28
|
+
interaction.member?.user?.username ?? interaction.user.username ?? "User";
|
|
29
|
+
const isGroup = interaction.guildId !== null;
|
|
30
|
+
// Strip the `ai:` prefix so the agent sees the original callback_data it
|
|
31
|
+
// emitted in send_message_with_buttons, not our routing namespace.
|
|
32
|
+
const rawId = interaction.customId.startsWith("ai:")
|
|
33
|
+
? interaction.customId.slice(3)
|
|
34
|
+
: interaction.customId;
|
|
35
|
+
const prompt = `[Button pressed] User clicked component with custom_id: "${rawId}"`;
|
|
36
|
+
const chatTitle = isGroup
|
|
37
|
+
? `${interaction.guild?.name ?? interaction.guildId} #${(interaction.channel as { name?: string } | null)?.name ?? interaction.channelId}`
|
|
38
|
+
: undefined;
|
|
39
|
+
|
|
40
|
+
appendDailyLog(sender, `Button: ${rawId}`, { chatTitle });
|
|
41
|
+
|
|
42
|
+
const onTextBlock = async (text: string) => {
|
|
43
|
+
if (!text.trim()) return;
|
|
44
|
+
if (!interaction.channel?.isSendable()) return;
|
|
45
|
+
const chunks = splitMessage(suppressMentions(text), DISCORD_MAX_TEXT);
|
|
46
|
+
for (const c of chunks) {
|
|
47
|
+
try {
|
|
48
|
+
await interaction.channel.send({
|
|
49
|
+
content: c,
|
|
50
|
+
allowedMentions: { parse: [] },
|
|
51
|
+
});
|
|
52
|
+
} catch (err) {
|
|
53
|
+
logError(
|
|
54
|
+
"discord",
|
|
55
|
+
`forwardToAgent send failed: ${err instanceof Error ? err.message : err}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
await execute({
|
|
63
|
+
chatId,
|
|
64
|
+
numericChatId,
|
|
65
|
+
prompt,
|
|
66
|
+
senderName: sender,
|
|
67
|
+
isGroup,
|
|
68
|
+
source: "message",
|
|
69
|
+
onEvent: async (event) => {
|
|
70
|
+
if (event.type === "assistant_message") {
|
|
71
|
+
await onTextBlock(event.text);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
} catch (err) {
|
|
76
|
+
logError(
|
|
77
|
+
"discord",
|
|
78
|
+
`Component → agent forward failed: ${err instanceof Error ? err.message : err}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `model:backend-select` — the /model backend switch.
|
|
3
|
+
*
|
|
4
|
+
* Rebinding drops this chat's session (it isn't portable across backends)
|
|
5
|
+
* but keeps each backend's remembered model pick, so switching back
|
|
6
|
+
* restores what was selected there before.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { type StringSelectMenuInteraction, MessageFlags } from "discord.js";
|
|
10
|
+
import {
|
|
11
|
+
buildModelPickerView,
|
|
12
|
+
MODEL_NAV_PREFIX,
|
|
13
|
+
MODEL_PAGE_SIZE,
|
|
14
|
+
} from "../../model-picker.js";
|
|
15
|
+
import { setChatBackend } from "../../../../storage/chat-settings.js";
|
|
16
|
+
import {
|
|
17
|
+
getBackendIdForChat,
|
|
18
|
+
resolveChatBackend,
|
|
19
|
+
listAvailableBackends,
|
|
20
|
+
rebindChat,
|
|
21
|
+
} from "../../../../core/engine/backend-controller/index.js";
|
|
22
|
+
import { resetSession } from "../../../../storage/sessions.js";
|
|
23
|
+
import { clearHistory } from "../../../../storage/history.js";
|
|
24
|
+
import { resetPulseCheckpoint } from "../../../../core/background/pulse.js";
|
|
25
|
+
import { resolveActiveModelForChat } from "../../../../core/models/active-model.js";
|
|
26
|
+
import { logError } from "../../../../util/log.js";
|
|
27
|
+
import { safeSlice } from "../../formatting.js";
|
|
28
|
+
import type { ComponentContext } from "./types.js";
|
|
29
|
+
|
|
30
|
+
export async function handleBackendSelect(
|
|
31
|
+
interaction: StringSelectMenuInteraction,
|
|
32
|
+
{ config, gateway, chatId }: ComponentContext,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
const backendId = interaction.values[0] ?? "";
|
|
35
|
+
const available = listAvailableBackends(config);
|
|
36
|
+
if (!available.some((b) => b.id === backendId)) {
|
|
37
|
+
await interaction.reply({
|
|
38
|
+
content: "Backend not available.",
|
|
39
|
+
flags: MessageFlags.Ephemeral,
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Booting a cold backend takes seconds — Kilo and OpenCode spawn a server
|
|
45
|
+
// and sweep MCP registrations — and Discord drops an interaction that
|
|
46
|
+
// isn't acknowledged within three. Ack first, edit the panel after.
|
|
47
|
+
try {
|
|
48
|
+
await interaction.deferUpdate();
|
|
49
|
+
} catch {
|
|
50
|
+
/* already acknowledged */
|
|
51
|
+
}
|
|
52
|
+
const fail = async (text: string): Promise<void> => {
|
|
53
|
+
try {
|
|
54
|
+
await interaction.followUp({
|
|
55
|
+
content: text,
|
|
56
|
+
flags: MessageFlags.Ephemeral,
|
|
57
|
+
});
|
|
58
|
+
} catch {
|
|
59
|
+
/* ignore */
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Resolve the outgoing backend before rebinding — afterwards the chat
|
|
64
|
+
// already points at the new one and the old instance is unreachable.
|
|
65
|
+
const previousId = getBackendIdForChat(chatId);
|
|
66
|
+
const previous = resolveChatBackend(chatId, gateway?.backend);
|
|
67
|
+
const alreadyThere = previousId === backendId;
|
|
68
|
+
const result = alreadyThere
|
|
69
|
+
? { ok: true as const }
|
|
70
|
+
: await rebindChat(chatId, backendId, config);
|
|
71
|
+
if (!result.ok) {
|
|
72
|
+
await fail(result.error?.slice(0, 200) ?? "Rebind failed.");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
setChatBackend(chatId, backendId);
|
|
76
|
+
|
|
77
|
+
// The pool accepts a backend whose SDK is installed, but the SDK may only
|
|
78
|
+
// be a client for a CLI that isn't (OpenCode and Kilo spawn `opencode` /
|
|
79
|
+
// `kilo`). That surfaces here, on the first real call. Reading the
|
|
80
|
+
// catalog is the cheapest way to find out, and it is needed for the panel
|
|
81
|
+
// anyway — so treat a throw as "this backend can't serve" and put the
|
|
82
|
+
// chat back where it was rather than stranding it somewhere unusable.
|
|
83
|
+
let next = resolveChatBackend(chatId, gateway?.backend);
|
|
84
|
+
let newModel: string | undefined;
|
|
85
|
+
let pres;
|
|
86
|
+
try {
|
|
87
|
+
newModel =
|
|
88
|
+
(await resolveActiveModelForChat(chatId, next, backendId, config))
|
|
89
|
+
.model ?? undefined;
|
|
90
|
+
if (next?.models?.getSettingsPresentation) {
|
|
91
|
+
pres = await next.models.getSettingsPresentation(newModel ?? "", {
|
|
92
|
+
callbackPrefix: "model:",
|
|
93
|
+
navCallbackPrefix: MODEL_NAV_PREFIX,
|
|
94
|
+
pageSize: MODEL_PAGE_SIZE,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
99
|
+
logError("discord", `Backend switch to ${backendId} failed`, err);
|
|
100
|
+
if (!alreadyThere) {
|
|
101
|
+
await rebindChat(chatId, previousId, config).catch(() => undefined);
|
|
102
|
+
setChatBackend(chatId, previousId);
|
|
103
|
+
}
|
|
104
|
+
await fail(
|
|
105
|
+
`⚠️ \`${backendId}\` could not start — staying on \`${previousId}\`.\n` +
|
|
106
|
+
`\`\`\`\n${safeSlice(reason, 300)}\n\`\`\``,
|
|
107
|
+
);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Only now is the switch known to hold. Session state doesn't port across
|
|
112
|
+
// backends, so it goes — but each backend's remembered model pick stays.
|
|
113
|
+
// A re-pick of the backend already in use clears nothing: the retry after
|
|
114
|
+
// a timed-out interaction must not cost the session a second time.
|
|
115
|
+
if (!alreadyThere) {
|
|
116
|
+
resetSession(chatId);
|
|
117
|
+
clearHistory(chatId);
|
|
118
|
+
resetPulseCheckpoint(chatId);
|
|
119
|
+
previous?.sessions?.resetChat?.(chatId);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
next = resolveChatBackend(chatId, gateway?.backend);
|
|
123
|
+
// Warming is fire-and-forget: it can take seconds on OpenCode/Kilo and
|
|
124
|
+
// the interaction still has a panel to redraw.
|
|
125
|
+
if (next && next !== previous) {
|
|
126
|
+
void Promise.resolve(next.sessions?.warmSession?.(chatId)).catch(
|
|
127
|
+
() => undefined,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
await interaction.editReply(
|
|
133
|
+
pres
|
|
134
|
+
? {
|
|
135
|
+
...buildModelPickerView(
|
|
136
|
+
pres,
|
|
137
|
+
newModel ?? "_No model selected_",
|
|
138
|
+
backendId,
|
|
139
|
+
),
|
|
140
|
+
}
|
|
141
|
+
: {
|
|
142
|
+
content: `**Backend:** \`${backendId}\` · model \`${newModel ?? "none"}\``,
|
|
143
|
+
components: [],
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
} catch {
|
|
147
|
+
/* ignore */
|
|
148
|
+
}
|
|
149
|
+
}
|