talon-agent 1.47.1 → 1.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/cli/index.ts +11 -0
- package/src/cli/tasks.ts +212 -0
- package/src/core/background/dream.ts +10 -0
- package/src/core/background/heartbeat/agent.ts +10 -0
- package/src/core/background/job-oneshot.ts +25 -9
- package/src/core/engine/gateway-actions/native.ts +61 -6
- package/src/core/engine/gateway.ts +34 -0
- package/src/core/tasks/index.ts +20 -0
- package/src/core/tasks/table.ts +161 -0
- package/src/core/tasks/types.ts +100 -0
- package/src/core/tools/bridge.ts +30 -5
- package/src/core/types.ts +6 -0
- package/src/core/weaver/weaver.ts +24 -2
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ Multi-platform agentic AI harness. Runs on **Telegram**, **Discord**, **Microsof
|
|
|
22
22
|
| **Goals** | Persistent multi-day objectives the agent commits to in chat; every heartbeat run re-reads them, makes progress, and records what it did |
|
|
23
23
|
| **Skills** | Agent-authored reusable scripts (bash/python/node) — procedures worked out once get saved and replayed locally at zero token cost |
|
|
24
24
|
| **Triggers** | Self-authored watcher scripts (bash/python/node) that wake the bot when conditions are met |
|
|
25
|
+
| **Task table** | Every unit of agent work — chat turns, heartbeat, dream, isolated cron/trigger jobs — registered live; `talon ps` / `talon kill` |
|
|
25
26
|
| **Per-chat settings** | Model, effort level, and pulse toggle per conversation via inline keyboard |
|
|
26
27
|
| **Model registry** | Models discovered from the active backend at startup — new models appear in all pickers automatically |
|
|
27
28
|
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { tailLogs } from "./logs.js";
|
|
|
30
30
|
import { runDoctor } from "./doctor.js";
|
|
31
31
|
import { startChat } from "./chat.js";
|
|
32
32
|
import { daemonStart, daemonStop, daemonRestart } from "./daemon.js";
|
|
33
|
+
import { showTasks, killTask } from "./tasks.js";
|
|
33
34
|
import { mainMenu } from "./menu.js";
|
|
34
35
|
|
|
35
36
|
export * from "./context.js";
|
|
@@ -55,6 +56,8 @@ const CLI_COMMANDS = [
|
|
|
55
56
|
"run",
|
|
56
57
|
"chat",
|
|
57
58
|
"doctor",
|
|
59
|
+
"ps",
|
|
60
|
+
"kill",
|
|
58
61
|
];
|
|
59
62
|
|
|
60
63
|
/** Route a `talon <command>` invocation. Called by the entry point. */
|
|
@@ -96,6 +99,12 @@ export async function runCli(): Promise<void> {
|
|
|
96
99
|
case "doctor":
|
|
97
100
|
runDoctor();
|
|
98
101
|
break;
|
|
102
|
+
case "ps":
|
|
103
|
+
await showTasks();
|
|
104
|
+
break;
|
|
105
|
+
case "kill":
|
|
106
|
+
await killTask(process.argv[3]);
|
|
107
|
+
break;
|
|
99
108
|
case "--version":
|
|
100
109
|
case "-v": {
|
|
101
110
|
console.log(pkg.version);
|
|
@@ -113,6 +122,8 @@ export async function runCli(): Promise<void> {
|
|
|
113
122
|
console.log(` ${pc.cyan("run")} Run in foreground (attached)`);
|
|
114
123
|
console.log(` ${pc.cyan("chat")} Terminal chat mode`);
|
|
115
124
|
console.log(` ${pc.cyan("status")} Show bot health`);
|
|
125
|
+
console.log(` ${pc.cyan("ps")} List agent tasks (task table)`);
|
|
126
|
+
console.log(` ${pc.cyan("kill")} Abort a killable task by id`);
|
|
116
127
|
console.log(` ${pc.cyan("config")} View/edit configuration`);
|
|
117
128
|
console.log(` ${pc.cyan("logs")} Tail log file`);
|
|
118
129
|
console.log(` ${pc.cyan("doctor")} Validate environment`);
|
package/src/cli/tasks.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `talon ps` / `talon kill` — the task table from the outside.
|
|
3
|
+
*
|
|
4
|
+
* Data comes from the running daemon's gateway (`GET /tasks`,
|
|
5
|
+
* `POST /tasks/kill`); discovery finds the instance the same way
|
|
6
|
+
* `talon status` does. This module only renders — the table itself
|
|
7
|
+
* lives in core/tasks/.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import pc from "picocolors";
|
|
11
|
+
import { findRunningInstance } from "../core/daemon/discovery.js";
|
|
12
|
+
import type {
|
|
13
|
+
KillOutcome,
|
|
14
|
+
TaskRecord,
|
|
15
|
+
TaskState,
|
|
16
|
+
} from "../core/tasks/index.js";
|
|
17
|
+
|
|
18
|
+
const REQUEST_TIMEOUT_MS = 3_000;
|
|
19
|
+
|
|
20
|
+
function formatDuration(ms: number): string {
|
|
21
|
+
const seconds = Math.floor(ms / 1000);
|
|
22
|
+
if (seconds < 60) return `${seconds}s`;
|
|
23
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
24
|
+
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function formatTokenCount(n: number): string {
|
|
28
|
+
if (n < 1000) return String(n);
|
|
29
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
|
|
30
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function formatTokens(task: TaskRecord): string {
|
|
34
|
+
if (!task.usage) return "—";
|
|
35
|
+
return `${formatTokenCount(task.usage.inputTokens)}→${formatTokenCount(task.usage.outputTokens)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Queued: wait so far. Running: runtime so far. Settled: total runtime. */
|
|
39
|
+
function formatTime(task: TaskRecord, now: number): string {
|
|
40
|
+
if (task.state === "queued") return formatDuration(now - task.queuedAt);
|
|
41
|
+
const start = task.startedAt ?? task.queuedAt;
|
|
42
|
+
return formatDuration((task.endedAt ?? now) - start);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function colorState(state: TaskState): string {
|
|
46
|
+
switch (state) {
|
|
47
|
+
case "running":
|
|
48
|
+
return pc.green(state);
|
|
49
|
+
case "queued":
|
|
50
|
+
return pc.yellow(state);
|
|
51
|
+
case "done":
|
|
52
|
+
return pc.dim(state);
|
|
53
|
+
case "failed":
|
|
54
|
+
return pc.red(state);
|
|
55
|
+
case "killed":
|
|
56
|
+
return pc.magenta(state);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Live tasks first (oldest running at the top), then history newest-first. */
|
|
61
|
+
function displayOrder(tasks: TaskRecord[]): TaskRecord[] {
|
|
62
|
+
const live = tasks.filter(
|
|
63
|
+
(t) => t.state === "running" || t.state === "queued",
|
|
64
|
+
);
|
|
65
|
+
const settled = tasks.filter(
|
|
66
|
+
(t) => t.state !== "running" && t.state !== "queued",
|
|
67
|
+
);
|
|
68
|
+
live.sort((a, b) => a.id - b.id);
|
|
69
|
+
settled.sort((a, b) => b.id - a.id);
|
|
70
|
+
return [...live, ...settled];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Row = readonly string[];
|
|
74
|
+
|
|
75
|
+
const HEADER: Row = ["ID", "STATE", "KIND", "TIME", "TOKENS", "LABEL", "CHAT"];
|
|
76
|
+
|
|
77
|
+
function toRow(task: TaskRecord, now: number): Row {
|
|
78
|
+
return [
|
|
79
|
+
String(task.id),
|
|
80
|
+
task.state,
|
|
81
|
+
task.kind,
|
|
82
|
+
formatTime(task, now),
|
|
83
|
+
formatTokens(task),
|
|
84
|
+
task.label,
|
|
85
|
+
task.chatId ?? "—",
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Pad every column to its widest cell. Colors are applied after padding
|
|
91
|
+
* (the state cell) so ANSI escapes never skew the width math.
|
|
92
|
+
*/
|
|
93
|
+
function renderTable(tasks: TaskRecord[], now: number): void {
|
|
94
|
+
const rows = tasks.map((t) => toRow(t, now));
|
|
95
|
+
const widths = HEADER.map((h, col) =>
|
|
96
|
+
Math.max(h.length, ...rows.map((r) => r[col]!.length)),
|
|
97
|
+
);
|
|
98
|
+
const pad = (row: Row) => row.map((cell, col) => cell.padEnd(widths[col]!));
|
|
99
|
+
|
|
100
|
+
console.log(` ${pc.dim(pad(HEADER).join(" "))}`);
|
|
101
|
+
tasks.forEach((task, i) => {
|
|
102
|
+
const cells = pad(rows[i]!);
|
|
103
|
+
cells[1] =
|
|
104
|
+
colorState(task.state) + " ".repeat(widths[1]! - task.state.length);
|
|
105
|
+
console.log(` ${cells.join(" ")}`);
|
|
106
|
+
});
|
|
107
|
+
console.log();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function fetchGateway(
|
|
111
|
+
port: number,
|
|
112
|
+
path: string,
|
|
113
|
+
init?: RequestInit,
|
|
114
|
+
): Promise<unknown> {
|
|
115
|
+
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
116
|
+
...init,
|
|
117
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
118
|
+
});
|
|
119
|
+
if (!response.ok) throw new Error(`gateway answered ${response.status}`);
|
|
120
|
+
return response.json();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Find the daemon and return its gateway port, or render why not. */
|
|
124
|
+
async function requireGatewayPort(): Promise<number | null> {
|
|
125
|
+
const instance = await findRunningInstance();
|
|
126
|
+
if (!instance) {
|
|
127
|
+
console.log(` ${pc.red("●")} Talon is not running\n`);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
if (!instance.port) {
|
|
131
|
+
console.log(
|
|
132
|
+
` ${pc.yellow("●")} Talon is running (PID ${instance.pid}) but its gateway port is unknown — possibly still starting.\n`,
|
|
133
|
+
);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return instance.port;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function showTasks(): Promise<void> {
|
|
140
|
+
console.log();
|
|
141
|
+
const port = await requireGatewayPort();
|
|
142
|
+
if (port === null) return;
|
|
143
|
+
|
|
144
|
+
let tasks: TaskRecord[];
|
|
145
|
+
try {
|
|
146
|
+
const body = (await fetchGateway(port, "/tasks")) as {
|
|
147
|
+
tasks?: TaskRecord[];
|
|
148
|
+
};
|
|
149
|
+
tasks = body.tasks ?? [];
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.log(
|
|
152
|
+
` ${pc.red("✖")} Could not read the task table: ${err instanceof Error ? err.message : err}\n`,
|
|
153
|
+
);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (tasks.length === 0) {
|
|
158
|
+
console.log(
|
|
159
|
+
` ${pc.dim("No tasks — the table starts empty on each daemon start.")}\n`,
|
|
160
|
+
);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
renderTable(displayOrder(tasks), Date.now());
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function killTask(rawId: string | undefined): Promise<void> {
|
|
167
|
+
console.log();
|
|
168
|
+
const id = Number(rawId);
|
|
169
|
+
if (rawId === undefined || !Number.isInteger(id)) {
|
|
170
|
+
console.log(
|
|
171
|
+
` ${pc.red("✖")} Usage: ${pc.cyan("talon kill <id>")} — ids come from ${pc.cyan("talon ps")}\n`,
|
|
172
|
+
);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const port = await requireGatewayPort();
|
|
177
|
+
if (port === null) return;
|
|
178
|
+
|
|
179
|
+
let outcome: KillOutcome;
|
|
180
|
+
try {
|
|
181
|
+
outcome = (await fetchGateway(port, "/tasks/kill", {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: { "Content-Type": "application/json" },
|
|
184
|
+
body: JSON.stringify({ id }),
|
|
185
|
+
})) as KillOutcome;
|
|
186
|
+
} catch (err) {
|
|
187
|
+
console.log(
|
|
188
|
+
` ${pc.red("✖")} Kill request failed: ${err instanceof Error ? err.message : err}\n`,
|
|
189
|
+
);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (outcome.ok) {
|
|
194
|
+
console.log(
|
|
195
|
+
` ${pc.green("●")} Task ${id} aborted — it settles as ${pc.magenta("killed")} once the backend honours the abort.\n`,
|
|
196
|
+
);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
switch (outcome.reason) {
|
|
200
|
+
case "not-found":
|
|
201
|
+
console.log(` ${pc.red("✖")} No task ${id} in the table.\n`);
|
|
202
|
+
return;
|
|
203
|
+
case "finished":
|
|
204
|
+
console.log(` ${pc.dim("●")} Task ${id} already finished.\n`);
|
|
205
|
+
return;
|
|
206
|
+
case "not-killable":
|
|
207
|
+
console.log(
|
|
208
|
+
` ${pc.yellow("!")} Task ${id} has no abort hook (chat turns run to completion) — it cannot be killed.\n`,
|
|
209
|
+
);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -22,6 +22,7 @@ import { getDefaultModel } from "../models/catalog.js";
|
|
|
22
22
|
import type { OneShotAgentParams } from "../types.js";
|
|
23
23
|
import type { Backend } from "../agent-runtime/capabilities.js";
|
|
24
24
|
import { getSoul } from "../soul/service.js";
|
|
25
|
+
import { taskTable } from "../tasks/index.js";
|
|
25
26
|
import { FailureBackoff } from "./failure-backoff.js";
|
|
26
27
|
|
|
27
28
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
@@ -259,6 +260,13 @@ If commands fail, log the error and continue — this stage is optional.`
|
|
|
259
260
|
|
|
260
261
|
const abortController = new AbortController();
|
|
261
262
|
|
|
263
|
+
const task = taskTable.begin({
|
|
264
|
+
kind: "dream",
|
|
265
|
+
label: "consolidation",
|
|
266
|
+
abort: () => abortController.abort(),
|
|
267
|
+
});
|
|
268
|
+
task.bind({ model });
|
|
269
|
+
|
|
262
270
|
const oneShotParams: OneShotAgentParams = {
|
|
263
271
|
prompt,
|
|
264
272
|
systemPrompt,
|
|
@@ -298,6 +306,7 @@ If commands fail, log the error and continue — this stage is optional.`
|
|
|
298
306
|
try {
|
|
299
307
|
await Promise.race([agentPromise, timeoutPromise]);
|
|
300
308
|
} catch (err) {
|
|
309
|
+
task.fail(err);
|
|
301
310
|
appendDreamLog(
|
|
302
311
|
dreamLogFile,
|
|
303
312
|
`\n---\n**Dream FAILED at ${new Date().toISOString()}:** ${err}\n`,
|
|
@@ -323,6 +332,7 @@ If commands fail, log the error and continue — this stage is optional.`
|
|
|
323
332
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
324
333
|
}
|
|
325
334
|
|
|
335
|
+
task.succeed();
|
|
326
336
|
return dreamLogFile;
|
|
327
337
|
}
|
|
328
338
|
|
|
@@ -12,6 +12,7 @@ import { toYMD } from "../../../util/time.js";
|
|
|
12
12
|
import { getDefaultModel } from "../../models/catalog.js";
|
|
13
13
|
import { loadSystemTemplate } from "../../prompt/templates.js";
|
|
14
14
|
import { formatGoal, getOpenGoals } from "../../../storage/goal-store.js";
|
|
15
|
+
import { taskTable } from "../../tasks/index.js";
|
|
15
16
|
import type { OneShotAgentParams } from "../../types.js";
|
|
16
17
|
import { hb } from "./state.js";
|
|
17
18
|
|
|
@@ -184,6 +185,13 @@ export async function runHeartbeatAgent(
|
|
|
184
185
|
// heartbeatAbortGraceMs below.
|
|
185
186
|
const abortController = new AbortController();
|
|
186
187
|
|
|
188
|
+
const task = taskTable.begin({
|
|
189
|
+
kind: "heartbeat",
|
|
190
|
+
label: `#${runCount}`,
|
|
191
|
+
abort: () => abortController.abort(),
|
|
192
|
+
});
|
|
193
|
+
task.bind({ model });
|
|
194
|
+
|
|
187
195
|
const oneShotParams: OneShotAgentParams = {
|
|
188
196
|
prompt,
|
|
189
197
|
systemPrompt: buildHeartbeatSystemPrompt(),
|
|
@@ -231,6 +239,7 @@ export async function runHeartbeatAgent(
|
|
|
231
239
|
clearTimeout(timeoutHandle);
|
|
232
240
|
timeoutHandle = null;
|
|
233
241
|
}
|
|
242
|
+
task.fail(err);
|
|
234
243
|
await appendHeartbeatLog(
|
|
235
244
|
heartbeatLogFile,
|
|
236
245
|
`\n---\n**Heartbeat #${runCount} FAILED at ${new Date().toISOString()}:** ${err}\n`,
|
|
@@ -269,6 +278,7 @@ export async function runHeartbeatAgent(
|
|
|
269
278
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
270
279
|
}
|
|
271
280
|
|
|
281
|
+
task.succeed();
|
|
272
282
|
return heartbeatLogFile;
|
|
273
283
|
}
|
|
274
284
|
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
acquireBackendInstance,
|
|
21
21
|
isModelValidForBackend,
|
|
22
22
|
} from "../engine/backend-controller/index.js";
|
|
23
|
+
import { taskTable } from "../tasks/index.js";
|
|
23
24
|
import type { OneShotAgentParams } from "../types.js";
|
|
24
25
|
import { runIsolatedAgent } from "./isolated-agent.js";
|
|
25
26
|
import {
|
|
@@ -132,6 +133,15 @@ export async function runJobOneShot(
|
|
|
132
133
|
params.model,
|
|
133
134
|
);
|
|
134
135
|
|
|
136
|
+
const abortController = new AbortController();
|
|
137
|
+
const task = taskTable.begin({
|
|
138
|
+
kind: params.kind,
|
|
139
|
+
label: params.label,
|
|
140
|
+
chatId: params.chatId,
|
|
141
|
+
abort: () => abortController.abort(),
|
|
142
|
+
});
|
|
143
|
+
task.bind({ model: params.model, backendId: params.backendId });
|
|
144
|
+
|
|
135
145
|
const oneShot: OneShotAgentParams = {
|
|
136
146
|
prompt: params.payload,
|
|
137
147
|
systemPrompt: buildJobSystemPrompt(
|
|
@@ -142,18 +152,24 @@ export async function runJobOneShot(
|
|
|
142
152
|
contextLabel: JOB_CONTEXT_LABEL,
|
|
143
153
|
workspace: dirs.workspace,
|
|
144
154
|
model: params.model,
|
|
145
|
-
abortController
|
|
155
|
+
abortController,
|
|
146
156
|
appendLog,
|
|
147
157
|
};
|
|
148
158
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
159
|
+
try {
|
|
160
|
+
await runIsolatedAgent({
|
|
161
|
+
background,
|
|
162
|
+
params: oneShot,
|
|
163
|
+
timeoutMs: params.timeoutMs ?? DEFAULT_JOB_TIMEOUT_MS,
|
|
164
|
+
// No evictLabel: the job context label is shared with heartbeat, so a
|
|
165
|
+
// sweep here could kill a concurrent heartbeat's subprocess. Bounded
|
|
166
|
+
// abort-grace is enough.
|
|
167
|
+
});
|
|
168
|
+
task.succeed();
|
|
169
|
+
} catch (err) {
|
|
170
|
+
task.fail(err);
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
157
173
|
log(
|
|
158
174
|
params.kind === "cron" ? "cron" : "triggers",
|
|
159
175
|
`isolated job "${params.label}" ran on ${params.backendId}/${params.model}`,
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
writeFile,
|
|
26
26
|
} from "node:fs/promises";
|
|
27
27
|
import { tmpdir } from "node:os";
|
|
28
|
-
import { dirname, join } from "node:path";
|
|
28
|
+
import { dirname, extname, join } from "node:path";
|
|
29
29
|
import { getMeshService } from "../../mesh/index.js";
|
|
30
30
|
import {
|
|
31
31
|
clearTeleport,
|
|
@@ -39,7 +39,12 @@ import {
|
|
|
39
39
|
} from "../../../util/exec-output.js";
|
|
40
40
|
import type { SharedActionHandlers } from "./types.js";
|
|
41
41
|
|
|
42
|
-
type Result = {
|
|
42
|
+
type Result = {
|
|
43
|
+
ok: boolean;
|
|
44
|
+
text: string;
|
|
45
|
+
/** Set for image files so the tool result carries a viewable image block. */
|
|
46
|
+
image?: { data: string; mimeType: string };
|
|
47
|
+
};
|
|
43
48
|
|
|
44
49
|
/**
|
|
45
50
|
* ripgrep binary, resolved from PATH (env-overridable for tests). NOT a
|
|
@@ -70,6 +75,21 @@ const TELEPORT_TIMEOUT_HINT =
|
|
|
70
75
|
"background it in-shell instead: `cmd > /tmp/out.log 2>&1 &`, then poll the log " +
|
|
71
76
|
"with read, or bound the command itself: `logcat -d`, `timeout 30 …`, `head -n 200`.)";
|
|
72
77
|
const MAX_READ_LINES = 2_000;
|
|
78
|
+
/**
|
|
79
|
+
* Image extensions the model can actually view, mapped to their MIME type.
|
|
80
|
+
* Reading one returns an image content block instead of the file's raw bytes
|
|
81
|
+
* decoded as (garbage) UTF-8 — so `read`ing a photo/screenshot/design shows
|
|
82
|
+
* the picture, not mojibake.
|
|
83
|
+
*/
|
|
84
|
+
const IMAGE_MIME: Record<string, string> = {
|
|
85
|
+
".png": "image/png",
|
|
86
|
+
".jpg": "image/jpeg",
|
|
87
|
+
".jpeg": "image/jpeg",
|
|
88
|
+
".gif": "image/gif",
|
|
89
|
+
".webp": "image/webp",
|
|
90
|
+
};
|
|
91
|
+
/** Anthropic caps a single image around 5MB; refuse larger with a downscale hint. */
|
|
92
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
73
93
|
/** Caps for the pure-JS glob/search fallbacks (rg unavailable). */
|
|
74
94
|
const MAX_JS_RESULTS = 2_000;
|
|
75
95
|
const MAX_JS_FILE_BYTES = 2 * 1024 * 1024;
|
|
@@ -437,23 +457,58 @@ async function read(
|
|
|
437
457
|
): Promise<Result> {
|
|
438
458
|
const p = str(path);
|
|
439
459
|
if (!p) return { ok: false, text: "A file path is required." };
|
|
460
|
+
const active = await getTeleport(chatId);
|
|
461
|
+
const where = active ? active.deviceName : "local";
|
|
462
|
+
|
|
463
|
+
// Image files: return a viewable image block, not the raw bytes decoded as
|
|
464
|
+
// UTF-8. Without this, `read`ing a photo/screenshot/design hands the model
|
|
465
|
+
// mojibake and it can't see the picture at all.
|
|
466
|
+
const mime = IMAGE_MIME[extname(p).toLowerCase()];
|
|
467
|
+
if (mime) {
|
|
468
|
+
let bytes: Buffer;
|
|
469
|
+
if (active) {
|
|
470
|
+
const res = await getMeshService().readFileBytes(active.deviceId, p);
|
|
471
|
+
if ("error" in res) return { ok: false, text: res.error };
|
|
472
|
+
bytes = res.data;
|
|
473
|
+
} else {
|
|
474
|
+
try {
|
|
475
|
+
bytes = await readFile(p);
|
|
476
|
+
} catch (err) {
|
|
477
|
+
return {
|
|
478
|
+
ok: false,
|
|
479
|
+
text: `Cannot read ${p}: ${(err as Error).message}`,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (bytes.length > MAX_IMAGE_BYTES) {
|
|
484
|
+
return {
|
|
485
|
+
ok: false,
|
|
486
|
+
text:
|
|
487
|
+
`${p} [${where}] is ${(bytes.length / 1_048_576).toFixed(1)}MB — over the ` +
|
|
488
|
+
`${MAX_IMAGE_BYTES / 1_048_576}MB image limit. Downscale it first ` +
|
|
489
|
+
`(e.g. \`convert '${p}' -resize 1568x /tmp/small.jpg\`) and read that.`,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
ok: true,
|
|
494
|
+
text: `${p} [${where}] — image (${mime}, ${bytes.length} bytes)`,
|
|
495
|
+
image: { data: bytes.toString("base64"), mimeType: mime },
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
440
499
|
const start = num(offset) ?? 0;
|
|
441
500
|
const max = Math.min(num(limit) ?? MAX_READ_LINES, MAX_READ_LINES);
|
|
442
|
-
const active = await getTeleport(chatId);
|
|
443
501
|
let content: string;
|
|
444
|
-
let where: string;
|
|
445
502
|
if (active) {
|
|
446
503
|
const res = await getMeshService().readFileBytes(active.deviceId, p);
|
|
447
504
|
if ("error" in res) return { ok: false, text: res.error };
|
|
448
505
|
content = res.data.toString("utf8");
|
|
449
|
-
where = active.deviceName;
|
|
450
506
|
} else {
|
|
451
507
|
try {
|
|
452
508
|
content = await readFile(p, "utf8");
|
|
453
509
|
} catch (err) {
|
|
454
510
|
return { ok: false, text: `Cannot read ${p}: ${(err as Error).message}` };
|
|
455
511
|
}
|
|
456
|
-
where = "local";
|
|
457
512
|
}
|
|
458
513
|
const lines = content.split("\n");
|
|
459
514
|
const slice = lines.slice(start, start + max);
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
HUB_PATH_PREFIX,
|
|
28
28
|
} from "../mcp-hub/index.js";
|
|
29
29
|
import { handlePluginAction } from "../plugin/index.js";
|
|
30
|
+
import { taskTable } from "../tasks/index.js";
|
|
30
31
|
import type { FrontendActionHandler } from "../types.js";
|
|
31
32
|
import { BOT_MESSAGE_ACTIONS, noteBotMessage } from "../soul/taps.js";
|
|
32
33
|
import type { Backend } from "../agent-runtime/capabilities.js";
|
|
@@ -395,6 +396,39 @@ export class Gateway {
|
|
|
395
396
|
return;
|
|
396
397
|
}
|
|
397
398
|
|
|
399
|
+
if (req.method === "GET" && req.url === "/tasks") {
|
|
400
|
+
// The task table — every live/recent unit of agent work. Read by
|
|
401
|
+
// `talon ps`. Same 127.0.0.1 trust boundary as /action.
|
|
402
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
403
|
+
res.end(JSON.stringify({ ok: true, tasks: taskTable.list() }));
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (req.method === "POST" && req.url === "/tasks/kill") {
|
|
408
|
+
// Abort one killable task by id — the transport for `talon kill`.
|
|
409
|
+
const chunks: Buffer[] = [];
|
|
410
|
+
for await (const chunk of req) chunks.push(chunk as Buffer);
|
|
411
|
+
let id: unknown;
|
|
412
|
+
try {
|
|
413
|
+
const body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
414
|
+
id = (body as { id?: unknown }).id;
|
|
415
|
+
} catch {
|
|
416
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
417
|
+
res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (typeof id !== "number" || !Number.isInteger(id)) {
|
|
421
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
422
|
+
res.end(
|
|
423
|
+
JSON.stringify({ ok: false, error: "id must be an integer" }),
|
|
424
|
+
);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
428
|
+
res.end(JSON.stringify(taskTable.kill(id)));
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
398
432
|
if (req.url?.startsWith(HUB_PATH_PREFIX)) {
|
|
399
433
|
// MCP hub — daemon-hosted MCP-over-HTTP endpoints for every
|
|
400
434
|
// backend (see core/mcp-hub). Same 127.0.0.1 trust boundary
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task table — public surface.
|
|
3
|
+
*
|
|
4
|
+
* See table.ts for the registry and types.ts for the vocabulary. Wiring
|
|
5
|
+
* points: weaver (turns), heartbeat/agent, dream, background/job-oneshot
|
|
6
|
+
* (isolated cron/trigger jobs); read surfaces: gateway `GET /tasks` +
|
|
7
|
+
* `POST /tasks/kill`, CLI `talon ps` / `talon kill`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { TaskTable, taskTable } from "./table.js";
|
|
11
|
+
export type {
|
|
12
|
+
KillOutcome,
|
|
13
|
+
TaskBinding,
|
|
14
|
+
TaskHandle,
|
|
15
|
+
TaskKind,
|
|
16
|
+
TaskRecord,
|
|
17
|
+
TaskSpec,
|
|
18
|
+
TaskState,
|
|
19
|
+
TaskUsage,
|
|
20
|
+
} from "./types.js";
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TaskTable — live registry plus bounded history of agent-work runs.
|
|
3
|
+
*
|
|
4
|
+
* One instance (`taskTable`) serves the whole daemon. Call sites register a
|
|
5
|
+
* run with `begin`/`enqueue` and settle it through the returned TaskHandle;
|
|
6
|
+
* the gateway serves `list()` over HTTP and routes `talon kill` to `kill()`.
|
|
7
|
+
*
|
|
8
|
+
* The table is observational: it never schedules, retries, or times out a
|
|
9
|
+
* run — those disciplines stay with the owning module (weaver, heartbeat,
|
|
10
|
+
* dream, job-oneshot). In-memory only by design: a task is a live run, and
|
|
11
|
+
* a daemon restart ends every run, so persisted rows could only describe
|
|
12
|
+
* work that no longer exists.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
KillOutcome,
|
|
17
|
+
TaskBinding,
|
|
18
|
+
TaskHandle,
|
|
19
|
+
TaskRecord,
|
|
20
|
+
TaskSpec,
|
|
21
|
+
TaskState,
|
|
22
|
+
TaskUsage,
|
|
23
|
+
} from "./types.js";
|
|
24
|
+
|
|
25
|
+
/** Settled tasks kept for `list()` after they leave the live map. */
|
|
26
|
+
const DEFAULT_HISTORY_LIMIT = 50;
|
|
27
|
+
|
|
28
|
+
type MutableTaskRecord = {
|
|
29
|
+
-readonly [K in keyof TaskRecord]: TaskRecord[K];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
interface LiveTask {
|
|
33
|
+
readonly record: MutableTaskRecord;
|
|
34
|
+
readonly abort?: () => void;
|
|
35
|
+
killRequested: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class TaskTable {
|
|
39
|
+
private readonly live = new Map<number, LiveTask>();
|
|
40
|
+
private readonly history: TaskRecord[] = [];
|
|
41
|
+
private readonly historyLimit: number;
|
|
42
|
+
private nextId = 1;
|
|
43
|
+
|
|
44
|
+
constructor(historyLimit = DEFAULT_HISTORY_LIMIT) {
|
|
45
|
+
this.historyLimit = historyLimit;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Register a run that starts immediately. */
|
|
49
|
+
begin(spec: TaskSpec): TaskHandle {
|
|
50
|
+
const handle = this.enqueue(spec);
|
|
51
|
+
handle.start();
|
|
52
|
+
return handle;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Register a run that is waiting its turn (e.g. a chat's FIFO queue). */
|
|
56
|
+
enqueue(spec: TaskSpec): TaskHandle {
|
|
57
|
+
const id = this.nextId++;
|
|
58
|
+
const record: MutableTaskRecord = {
|
|
59
|
+
id,
|
|
60
|
+
kind: spec.kind,
|
|
61
|
+
label: spec.label,
|
|
62
|
+
state: "queued",
|
|
63
|
+
killable: spec.abort !== undefined,
|
|
64
|
+
queuedAt: Date.now(),
|
|
65
|
+
};
|
|
66
|
+
if (spec.chatId !== undefined) record.chatId = spec.chatId;
|
|
67
|
+
const task: LiveTask = {
|
|
68
|
+
record,
|
|
69
|
+
killRequested: false,
|
|
70
|
+
...(spec.abort !== undefined ? { abort: spec.abort } : {}),
|
|
71
|
+
};
|
|
72
|
+
this.live.set(id, task);
|
|
73
|
+
return {
|
|
74
|
+
id,
|
|
75
|
+
start: () => this.start(id),
|
|
76
|
+
bind: (binding) => this.bind(id, binding),
|
|
77
|
+
succeed: (usage) => this.settle(id, "done", undefined, usage),
|
|
78
|
+
fail: (error) => this.settle(id, "failed", error),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Request an abort of a live killable task. Returns immediately — the
|
|
84
|
+
* task stays `running` until its owner's failure path lands, at which
|
|
85
|
+
* point it settles as `killed`. Repeat kills are no-ops that still
|
|
86
|
+
* report `ok` (the request stands); the abort hook fires only once.
|
|
87
|
+
*/
|
|
88
|
+
kill(id: number): KillOutcome {
|
|
89
|
+
const task = this.live.get(id);
|
|
90
|
+
if (!task) {
|
|
91
|
+
return this.history.some((record) => record.id === id)
|
|
92
|
+
? { ok: false, reason: "finished" }
|
|
93
|
+
: { ok: false, reason: "not-found" };
|
|
94
|
+
}
|
|
95
|
+
if (!task.abort) return { ok: false, reason: "not-killable" };
|
|
96
|
+
if (!task.killRequested) {
|
|
97
|
+
task.killRequested = true;
|
|
98
|
+
try {
|
|
99
|
+
task.abort();
|
|
100
|
+
} catch {
|
|
101
|
+
// The abort hook contract is "must not throw" — a violation must
|
|
102
|
+
// not break the kill path for the caller.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { ok: true };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Every live task plus the bounded settled history, id-ascending.
|
|
110
|
+
* Snapshots are copies — callers can never mutate table state.
|
|
111
|
+
*/
|
|
112
|
+
list(): TaskRecord[] {
|
|
113
|
+
const records: TaskRecord[] = this.history.map((record) => ({
|
|
114
|
+
...record,
|
|
115
|
+
}));
|
|
116
|
+
for (const task of this.live.values()) records.push({ ...task.record });
|
|
117
|
+
return records.sort((a, b) => a.id - b.id);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private start(id: number): void {
|
|
121
|
+
const task = this.live.get(id);
|
|
122
|
+
if (!task || task.record.state !== "queued") return;
|
|
123
|
+
task.record.state = "running";
|
|
124
|
+
task.record.startedAt = Date.now();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private bind(id: number, binding: TaskBinding): void {
|
|
128
|
+
const task = this.live.get(id);
|
|
129
|
+
if (!task) return;
|
|
130
|
+
if (binding.model !== undefined) task.record.model = binding.model;
|
|
131
|
+
if (binding.backendId !== undefined)
|
|
132
|
+
task.record.backendId = binding.backendId;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private settle(
|
|
136
|
+
id: number,
|
|
137
|
+
state: Extract<TaskState, "done" | "failed">,
|
|
138
|
+
error?: unknown,
|
|
139
|
+
usage?: TaskUsage,
|
|
140
|
+
): void {
|
|
141
|
+
const task = this.live.get(id);
|
|
142
|
+
if (!task) return;
|
|
143
|
+
const { record } = task;
|
|
144
|
+
// A kill only "takes" when the run actually dies: a run that completes
|
|
145
|
+
// despite the abort settles as done, one that unwinds settles as killed.
|
|
146
|
+
record.state = state === "failed" && task.killRequested ? "killed" : state;
|
|
147
|
+
record.endedAt = Date.now();
|
|
148
|
+
if (error !== undefined) {
|
|
149
|
+
record.error = error instanceof Error ? error.message : String(error);
|
|
150
|
+
}
|
|
151
|
+
if (usage !== undefined) record.usage = usage;
|
|
152
|
+
this.live.delete(id);
|
|
153
|
+
this.history.push(record);
|
|
154
|
+
if (this.history.length > this.historyLimit) {
|
|
155
|
+
this.history.splice(0, this.history.length - this.historyLimit);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The daemon-wide table. Tests needing isolation construct their own. */
|
|
161
|
+
export const taskTable = new TaskTable();
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task table vocabulary — the daemon's registry of agent work.
|
|
3
|
+
*
|
|
4
|
+
* A task is one bounded run of agent work: a chat turn, a heartbeat pass, a
|
|
5
|
+
* dream consolidation, or an isolated cron/trigger job. The table gives every
|
|
6
|
+
* such run an id, a lifecycle, an owner chat, and — where the run can be
|
|
7
|
+
* aborted — a kill switch. It is the process-table analogue for the agent
|
|
8
|
+
* runtime, with tokens (not CPU) as the accounted resource.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately NOT tasks: trigger watcher scripts (long-lived OS processes
|
|
11
|
+
* owned by the trigger store, which already tracks their pid/status), cron
|
|
12
|
+
* "message" jobs (a single send, no agent run), and the pulse ticker.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Which unit of agent work a task represents. */
|
|
16
|
+
export type TaskKind = "turn" | "heartbeat" | "dream" | "cron" | "trigger";
|
|
17
|
+
|
|
18
|
+
/** Task lifecycle. `done`, `failed`, and `killed` are terminal. */
|
|
19
|
+
export type TaskState = "queued" | "running" | "done" | "failed" | "killed";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Token spend for the run, captured at settlement when the runner reports
|
|
23
|
+
* it. Chat turns report usage today; isolated one-shots run through
|
|
24
|
+
* `runOneShotAgent`, which returns no usage, so their tasks settle without.
|
|
25
|
+
*/
|
|
26
|
+
export interface TaskUsage {
|
|
27
|
+
readonly inputTokens: number;
|
|
28
|
+
readonly outputTokens: number;
|
|
29
|
+
readonly cacheRead: number;
|
|
30
|
+
readonly cacheWrite: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What a call site declares when it registers a run with the table. */
|
|
34
|
+
export interface TaskSpec {
|
|
35
|
+
readonly kind: TaskKind;
|
|
36
|
+
/**
|
|
37
|
+
* Short human label shown in `talon ps`: the turn source ("message",
|
|
38
|
+
* "trigger", …), the cron job or trigger name, or the heartbeat run
|
|
39
|
+
* number. Never message content — the listing must not leak chat text.
|
|
40
|
+
*/
|
|
41
|
+
readonly label: string;
|
|
42
|
+
/** Chat the work belongs to, when it belongs to one. */
|
|
43
|
+
readonly chatId?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Abort the run mid-flight (typically `AbortController.abort`). Present ⇒
|
|
46
|
+
* the task is killable via `TaskTable.kill`. Must not throw; the table
|
|
47
|
+
* swallows a throwing abort so a broken hook cannot break the kill path.
|
|
48
|
+
*/
|
|
49
|
+
readonly abort?: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The model/backend a task actually resolved to, once known. */
|
|
53
|
+
export interface TaskBinding {
|
|
54
|
+
readonly model?: string;
|
|
55
|
+
readonly backendId?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Immutable snapshot of one task, as returned by `TaskTable.list()`. */
|
|
59
|
+
export interface TaskRecord {
|
|
60
|
+
readonly id: number;
|
|
61
|
+
readonly kind: TaskKind;
|
|
62
|
+
readonly label: string;
|
|
63
|
+
readonly chatId?: string;
|
|
64
|
+
readonly state: TaskState;
|
|
65
|
+
readonly killable: boolean;
|
|
66
|
+
/** When the task entered the table (equal to `startedAt` unless queued). */
|
|
67
|
+
readonly queuedAt: number;
|
|
68
|
+
readonly startedAt?: number;
|
|
69
|
+
readonly endedAt?: number;
|
|
70
|
+
readonly model?: string;
|
|
71
|
+
readonly backendId?: string;
|
|
72
|
+
/** Failure detail. Also set for killed tasks (the abort's error). */
|
|
73
|
+
readonly error?: string;
|
|
74
|
+
readonly usage?: TaskUsage;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Mutator handed to the owning call site. Every transition is idempotent:
|
|
79
|
+
* once a task settles, later calls (including a duplicate settle) no-op, so
|
|
80
|
+
* error paths never need to guard against double settlement.
|
|
81
|
+
*/
|
|
82
|
+
export interface TaskHandle {
|
|
83
|
+
readonly id: number;
|
|
84
|
+
/** Move a queued task to running. No-op unless queued. */
|
|
85
|
+
start(): void;
|
|
86
|
+
/** Record the model/backend the run resolved to. No-op once settled. */
|
|
87
|
+
bind(binding: TaskBinding): void;
|
|
88
|
+
/** Settle as `done`, optionally recording token usage. */
|
|
89
|
+
succeed(usage?: TaskUsage): void;
|
|
90
|
+
/** Settle as `failed` — or `killed` when a kill was requested first. */
|
|
91
|
+
fail(error: unknown): void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Result of `TaskTable.kill`. */
|
|
95
|
+
export type KillOutcome =
|
|
96
|
+
| { readonly ok: true }
|
|
97
|
+
| {
|
|
98
|
+
readonly ok: false;
|
|
99
|
+
readonly reason: "not-found" | "finished" | "not-killable";
|
|
100
|
+
};
|
package/src/core/tools/bridge.ts
CHANGED
|
@@ -125,14 +125,39 @@ export function createBridge(
|
|
|
125
125
|
|
|
126
126
|
/** Wrap a bridge result into the MCP content format. */
|
|
127
127
|
export function textResult(result: unknown): {
|
|
128
|
-
content: Array<
|
|
128
|
+
content: Array<
|
|
129
|
+
| { type: "text"; text: string }
|
|
130
|
+
| { type: "image"; data: string; mimeType: string }
|
|
131
|
+
>;
|
|
129
132
|
isError?: boolean;
|
|
130
133
|
} {
|
|
131
|
-
const r = result as {
|
|
134
|
+
const r = result as {
|
|
135
|
+
ok?: boolean;
|
|
136
|
+
text?: string;
|
|
137
|
+
error?: string;
|
|
138
|
+
image?: { data?: unknown; mimeType?: unknown };
|
|
139
|
+
};
|
|
140
|
+
const content: Array<
|
|
141
|
+
| { type: "text"; text: string }
|
|
142
|
+
| { type: "image"; data: string; mimeType: string }
|
|
143
|
+
> = [{ type: "text" as const, text: r.text ?? JSON.stringify(result) }];
|
|
144
|
+
// A result may carry an image (e.g. `read` on a photo) — surface it as an
|
|
145
|
+
// MCP image block so the model actually sees the picture, not base64 text.
|
|
146
|
+
if (
|
|
147
|
+
r &&
|
|
148
|
+
typeof r === "object" &&
|
|
149
|
+
r.image &&
|
|
150
|
+
typeof r.image.data === "string" &&
|
|
151
|
+
typeof r.image.mimeType === "string"
|
|
152
|
+
) {
|
|
153
|
+
content.push({
|
|
154
|
+
type: "image" as const,
|
|
155
|
+
data: r.image.data,
|
|
156
|
+
mimeType: r.image.mimeType,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
132
159
|
return {
|
|
133
|
-
content
|
|
134
|
-
{ type: "text" as const, text: r.text ?? JSON.stringify(result) },
|
|
135
|
-
],
|
|
160
|
+
content,
|
|
136
161
|
// Gateway results carry ok:false on failure — mark those as tool errors
|
|
137
162
|
// so the model treats the message as a failure, not a success payload.
|
|
138
163
|
...(r && typeof r === "object" && r.ok === false ? { isError: true } : {}),
|
package/src/core/types.ts
CHANGED
|
@@ -233,6 +233,12 @@ export type ActionResult = {
|
|
|
233
233
|
* tool calls (react/edit/delete) that target this message.
|
|
234
234
|
*/
|
|
235
235
|
message_id?: number | string;
|
|
236
|
+
/**
|
|
237
|
+
* Set by tools that return a viewable image (e.g. `read` on a photo). The
|
|
238
|
+
* MCP layer turns this into an image content block so the model sees the
|
|
239
|
+
* picture instead of base64 text.
|
|
240
|
+
*/
|
|
241
|
+
image?: { data: string; mimeType: string };
|
|
236
242
|
[key: string]: unknown;
|
|
237
243
|
};
|
|
238
244
|
|
|
@@ -24,6 +24,7 @@ import type { AgentResult } from "../agent-runtime/events.js";
|
|
|
24
24
|
import type { ModelRef } from "../agent-runtime/model-ref.js";
|
|
25
25
|
import type { MemoryRetriever } from "../memory/retrieval.js";
|
|
26
26
|
import type { ContextManager, ExecuteParams, ExecuteResult } from "../types.js";
|
|
27
|
+
import { taskTable, type TaskHandle } from "../tasks/index.js";
|
|
27
28
|
import { log, logDebug, logWarn } from "../../util/log.js";
|
|
28
29
|
import { Loom } from "./loom.js";
|
|
29
30
|
import { prefetchMemory } from "./memory-prefetch.js";
|
|
@@ -80,7 +81,14 @@ export class Weaver {
|
|
|
80
81
|
|
|
81
82
|
runTurn(params: ExecuteParams): Promise<ExecuteResult> {
|
|
82
83
|
const thread = this.loom.thread(params.chatId);
|
|
83
|
-
|
|
84
|
+
// Registered before enqueueing so a turn waiting in its chat's FIFO is
|
|
85
|
+
// visible as `queued` in the task table, not invisible until it runs.
|
|
86
|
+
const task = taskTable.enqueue({
|
|
87
|
+
kind: "turn",
|
|
88
|
+
label: params.source,
|
|
89
|
+
chatId: params.chatId,
|
|
90
|
+
});
|
|
91
|
+
return thread.enqueue(() => this.run(thread, params, task));
|
|
84
92
|
}
|
|
85
93
|
|
|
86
94
|
/** Number of turns currently running (not queued) across all chats. */
|
|
@@ -100,10 +108,22 @@ export class Weaver {
|
|
|
100
108
|
private async run(
|
|
101
109
|
thread: Thread,
|
|
102
110
|
params: ExecuteParams,
|
|
111
|
+
task: TaskHandle,
|
|
103
112
|
): Promise<ExecuteResult> {
|
|
104
113
|
this.activeCount++;
|
|
114
|
+
task.start();
|
|
105
115
|
try {
|
|
106
|
-
|
|
116
|
+
const result = await this.executeInner(thread, params, task);
|
|
117
|
+
task.succeed({
|
|
118
|
+
inputTokens: result.inputTokens,
|
|
119
|
+
outputTokens: result.outputTokens,
|
|
120
|
+
cacheRead: result.cacheRead,
|
|
121
|
+
cacheWrite: result.cacheWrite,
|
|
122
|
+
});
|
|
123
|
+
return result;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
task.fail(err);
|
|
126
|
+
throw err;
|
|
107
127
|
} finally {
|
|
108
128
|
this.activeCount--;
|
|
109
129
|
}
|
|
@@ -112,6 +132,7 @@ export class Weaver {
|
|
|
112
132
|
private async executeInner(
|
|
113
133
|
thread: Thread,
|
|
114
134
|
params: ExecuteParams,
|
|
135
|
+
task: TaskHandle,
|
|
115
136
|
): Promise<ExecuteResult> {
|
|
116
137
|
const { context, onActivity, retrieveMemory } = this.deps;
|
|
117
138
|
const backend = this.deps.getBackend(params.chatId);
|
|
@@ -151,6 +172,7 @@ export class Weaver {
|
|
|
151
172
|
overridden: warp.overridden,
|
|
152
173
|
boundAt: Date.now(),
|
|
153
174
|
});
|
|
175
|
+
task.bind({ model: warp.ref.id, backendId: warp.backendId });
|
|
154
176
|
if (drifted && previous) {
|
|
155
177
|
logDebug(
|
|
156
178
|
"dispatcher",
|