talon-agent 1.35.0 → 1.37.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.
Files changed (46) hide show
  1. package/package.json +3 -3
  2. package/src/backend/claude-sdk/handler.ts +6 -2
  3. package/src/backend/claude-sdk/options.ts +41 -1
  4. package/src/backend/codex/handler/events.ts +1 -1
  5. package/src/backend/codex/handler/message.ts +1 -0
  6. package/src/backend/kilo/handler/message.ts +1 -0
  7. package/src/backend/kilo/handler/turn.ts +1 -0
  8. package/src/backend/openai-agents/handler/events.ts +1 -1
  9. package/src/backend/openai-agents/handler/message.ts +5 -1
  10. package/src/backend/opencode/handler/message.ts +1 -0
  11. package/src/backend/opencode/handler/turn.ts +1 -0
  12. package/src/backend/remote-server/events.ts +3 -1
  13. package/src/backend/shared/metrics.ts +26 -51
  14. package/src/bootstrap.ts +1 -0
  15. package/src/core/engine/gateway-actions/index.ts +2 -0
  16. package/src/core/engine/gateway-actions/mesh.ts +27 -0
  17. package/src/core/engine/gateway-actions/native.ts +607 -0
  18. package/src/core/mcp-hub/index.ts +3 -0
  19. package/src/core/mcp-hub/talon-server.ts +3 -0
  20. package/src/core/mesh/persist.ts +49 -0
  21. package/src/core/mesh/registry.ts +10 -18
  22. package/src/core/mesh/service.ts +754 -42
  23. package/src/core/mesh/teleport.ts +158 -0
  24. package/src/core/mesh/transfers.ts +186 -0
  25. package/src/core/tools/bridge.ts +85 -4
  26. package/src/core/tools/index.ts +23 -2
  27. package/src/core/tools/mesh.ts +83 -1
  28. package/src/core/tools/native.ts +138 -0
  29. package/src/core/tools/types.ts +2 -1
  30. package/src/frontend/discord/commands/admin.ts +9 -2
  31. package/src/frontend/discord/helpers.ts +7 -6
  32. package/src/frontend/native/chats.ts +2 -2
  33. package/src/frontend/native/index.ts +2 -0
  34. package/src/frontend/native/server.ts +40 -1
  35. package/src/frontend/telegram/commands/admin.ts +10 -2
  36. package/src/frontend/telegram/helpers/diagnostics.ts +7 -6
  37. package/src/storage/db.ts +6 -1
  38. package/src/storage/repositories/sessions-repo.ts +20 -1
  39. package/src/storage/sessions.ts +348 -4
  40. package/src/storage/sql/db.sql +5 -0
  41. package/src/storage/sql/schema.sql +2 -1
  42. package/src/storage/sql/sessions.sql +3 -3
  43. package/src/storage/sql/statements.generated.ts +8 -4
  44. package/src/util/config.ts +10 -0
  45. package/src/util/exec-output.ts +64 -0
  46. package/src/util/metrics.ts +148 -60
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.35.0",
3
+ "version": "1.37.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -101,7 +101,7 @@
101
101
  "@kilocode/sdk": "^7.2.22",
102
102
  "@modelcontextprotocol/sdk": "^1.29.0",
103
103
  "@openai/agents": "^0.13.0",
104
- "@openai/codex-sdk": "^0.143.0",
104
+ "@openai/codex-sdk": "^0.144.0",
105
105
  "@opencode-ai/sdk": "^1.17.4",
106
106
  "@playwright/mcp": "^0.0.77",
107
107
  "@types/cross-spawn": "^6.0.6",
@@ -133,7 +133,7 @@
133
133
  "knip": "^6.3.1",
134
134
  "oxlint": "^1.59.0",
135
135
  "prettier": "^3.8.1",
136
- "typescript": "^6.0.2",
136
+ "typescript": "^7.0.2",
137
137
  "vitest": "^4.1.3"
138
138
  },
139
139
  "overrides": {
@@ -336,7 +336,7 @@ export async function* runChatTurn(
336
336
  }
337
337
 
338
338
  for (const tool of result.tools) {
339
- recordToolCall(tool.name, "claude");
339
+ recordToolCall(chatId, tool.name, "claude");
340
340
  captureIntoState(tool.name, tool.input);
341
341
  if (isTurnTerminator(tool.name, tool.input)) {
342
342
  state.turnTerminated = true;
@@ -487,6 +487,7 @@ export async function* runChatTurn(
487
487
 
488
488
  const durationMs = Date.now() - t0;
489
489
  recordTurnMetrics({
490
+ chatId,
490
491
  backend: "claude",
491
492
  durationMs,
492
493
  toolCalls: state.toolCalls,
@@ -543,7 +544,10 @@ export async function* runChatTurn(
543
544
  : ({ violated: false } as const);
544
545
 
545
546
  if (violation.violated) {
546
- recordFlowViolation(violation.shouldRetry ? "retried" : "cap_exhausted");
547
+ recordFlowViolation(
548
+ chatId,
549
+ violation.shouldRetry ? "retried" : "cap_exhausted",
550
+ );
547
551
  log(
548
552
  "agent",
549
553
  `[${chatId}] flow violation: ${violation.reason}. ${
@@ -30,6 +30,23 @@ import { log, logError } from "../../util/log.js";
30
30
  import { getConfig, getBridgePort } from "./state.js";
31
31
  import { ALLOWED_TOOLS_CHAT, EFFORT_MAP } from "./constants.js";
32
32
 
33
+ /**
34
+ * Built-in SDK tools that Talon's native tool set replaces when
35
+ * `config.nativeTools` is on. The shell/filesystem built-ins map 1:1 to
36
+ * bash/read/write/edit/glob/search; `Agent` (sub-agent dispatch) is dropped
37
+ * alongside them per the owner's preference for the native surface.
38
+ */
39
+ const NATIVE_REPLACED_BUILTINS = new Set<string>([
40
+ "Read",
41
+ "Write",
42
+ "Edit",
43
+ "NotebookEdit",
44
+ "Bash",
45
+ "Glob",
46
+ "Grep",
47
+ "Agent",
48
+ ]);
49
+
33
50
  // ── Types ────────────────────────────────────────────────────────────────────
34
51
 
35
52
  export type BuildSdkOptionsResult = {
@@ -61,8 +78,19 @@ export type HubMcpEntry = {
61
78
  type: "http";
62
79
  url: string;
63
80
  alwaysLoad?: boolean;
81
+ /** Per-server tool-call timeout in ms (overrides MCP_TOOL_TIMEOUT). */
82
+ timeout?: number;
64
83
  };
65
84
 
85
+ /**
86
+ * Tool-call timeout for the Talon frontend tool servers. Must sit ABOVE the
87
+ * bridge's largest per-action budget (1h for uncapped device file transfers)
88
+ * so the layer that times out is the bridge — whose error names the action,
89
+ * the budget, and warns the operation may still be running — rather than the
90
+ * SDK's generic MCP timeout, which tells the model nothing actionable.
91
+ */
92
+ const FRONTEND_TOOL_CALL_TIMEOUT_MS = 3_900_000; // 65 min
93
+
66
94
  /**
67
95
  * Build the MCP servers map for a chat query.
68
96
  * Includes frontend-specific tool servers and Brave Search, if configured.
@@ -102,6 +130,8 @@ export function buildMcpServers(chatId: string): Record<string, HubMcpEntry> {
102
130
  // lever here. Cost: ~50 frontend tool schemas loaded every turn (~10k
103
131
  // tokens, cached, negligible on the 1M-context models Talon runs).
104
132
  alwaysLoad: true,
133
+ // Outlast the bridge's 1h transfer budget — see the constant's doc.
134
+ timeout: FRONTEND_TOOL_CALL_TIMEOUT_MS,
105
135
  };
106
136
  }
107
137
 
@@ -388,7 +418,17 @@ export function buildSdkOptions(
388
418
  // WebFetch, Monitor, PushNotification, RemoteTrigger, Plan/Worktree/Todo
389
419
  // helpers, AskUserQuestion, ScheduleWakeup) is unavailable to the model.
390
420
  // MCP tools are governed independently via `mcpServers` below.
391
- tools: [...ALLOWED_TOOLS_CHAT],
421
+ //
422
+ // When `config.nativeTools` is on, the SDK's built-in shell/filesystem
423
+ // tools are dropped in favour of Talon's own native tools (which also
424
+ // teleport onto companion devices), and Agent (sub-agent dispatch) is
425
+ // removed too — the owner prefers the native surface without nested
426
+ // agents. Flip the flag back off to restore the built-ins instantly.
427
+ tools: config.nativeTools
428
+ ? ALLOWED_TOOLS_CHAT.filter(
429
+ (t) => !NATIVE_REPLACED_BUILTINS.has(t as string),
430
+ )
431
+ : [...ALLOWED_TOOLS_CHAT],
392
432
  ...thinkingConfig,
393
433
  mcpServers: {
394
434
  ...buildMcpServers(chatId),
@@ -182,7 +182,7 @@ function recordCodexToolMetric(
182
182
  // Shared vocabulary: `tool_calls.<bare>` (prefix-stripped, so codex
183
183
  // MCP calls land on the same keys as every other backend) plus the
184
184
  // `backend.codex.tool_calls` dimension.
185
- recordToolCall(toolName, "codex");
185
+ recordToolCall(ctx.chatId, toolName, "codex");
186
186
  ctx.codexToolMetrics.count += 1;
187
187
  }
188
188
 
@@ -571,6 +571,7 @@ export async function handleMessage(
571
571
  const responseText = finalizeResponseText(streamState);
572
572
  const durationMs = Date.now() - t0;
573
573
  recordTurnMetrics({
574
+ chatId,
574
575
  backend: "codex",
575
576
  durationMs,
576
577
  toolCalls: codexToolMetrics.count,
@@ -220,6 +220,7 @@ export async function handleMessage(
220
220
  const responseText = finalizeResponseText(state);
221
221
  const durationMs = Date.now() - t0;
222
222
  recordTurnMetrics({
223
+ chatId,
223
224
  backend: "kilo",
224
225
  durationMs,
225
226
  toolCalls: state.toolCalls,
@@ -289,6 +289,7 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
289
289
  }
290
290
 
291
291
  const outcome = await processStreamEvent(event, {
292
+ chatId,
292
293
  sessionId,
293
294
  state,
294
295
  seenToolCallIds,
@@ -103,7 +103,7 @@ function handleToolCalled(item: unknown, ctx: HandleRunItemContext): void {
103
103
  }
104
104
  }
105
105
 
106
- recordToolCall(toolName, "openai-agents");
106
+ recordToolCall(ctx.chatId, toolName, "openai-agents");
107
107
  recordToolUse(ctx.state, toolName, input);
108
108
 
109
109
  if (ctx.onToolUse) {
@@ -398,6 +398,7 @@ export async function handleMessage(
398
398
  const responseText = finalizeResponseText(streamState);
399
399
  const durationMs = Date.now() - t0;
400
400
  recordTurnMetrics({
401
+ chatId,
401
402
  backend: "openai-agents",
402
403
  durationMs,
403
404
  toolCalls: streamState.toolCalls,
@@ -443,7 +444,10 @@ export async function handleMessage(
443
444
  : ({ violated: false } as const);
444
445
 
445
446
  if (violation.violated) {
446
- recordFlowViolation(violation.shouldRetry ? "retried" : "cap_exhausted");
447
+ recordFlowViolation(
448
+ chatId,
449
+ violation.shouldRetry ? "retried" : "cap_exhausted",
450
+ );
447
451
  log(
448
452
  "agent",
449
453
  `[${chatId}] flow violation: trailing prose (${violation.trailing.length} chars) without end_turn/send. ${
@@ -232,6 +232,7 @@ export async function handleMessage(
232
232
  const responseText = finalizeResponseText(state);
233
233
  const durationMs = Date.now() - t0;
234
234
  recordTurnMetrics({
235
+ chatId,
235
236
  backend: "opencode",
236
237
  durationMs,
237
238
  toolCalls: state.toolCalls,
@@ -277,6 +277,7 @@ async function subscribeToTurnEvents(inputs: SubscribeInputs): Promise<void> {
277
277
  }
278
278
 
279
279
  const outcome = await processStreamEvent(event, {
280
+ chatId,
280
281
  sessionId,
281
282
  state,
282
283
  seenToolCallIds,
@@ -63,6 +63,8 @@ export type ProcessEventOutcome =
63
63
 
64
64
  /** Common context passed to the per-event helper. */
65
65
  export interface EventProcessingContext {
66
+ /** Chat id for session-scoped metrics. */
67
+ chatId: string;
66
68
  /** Session id we're scoped to — events for other sessions are dropped. */
67
69
  sessionId: string;
68
70
  /** Stream state accumulator (shared/). */
@@ -277,7 +279,7 @@ async function processPartUpdate(
277
279
  }
278
280
  // Count every tool the model calls — shared vocabulary across
279
281
  // backends (prefix-stripped name + backend dimension).
280
- recordToolCall(toolName, ctx.backendLabel.toLowerCase());
282
+ recordToolCall(ctx.chatId, toolName, ctx.backendLabel.toLowerCase());
281
283
  recordToolUse(ctx.state, toolName, input);
282
284
  if (ctx.onToolUse) {
283
285
  try {
@@ -38,10 +38,14 @@
38
38
  * registry. Never interpolate chat ids, model ids, or user input.
39
39
  */
40
40
 
41
- import { incrementCounter, recordHistogram } from "../../util/metrics.js";
42
41
  import { stripMcpPrefix } from "../../core/tools/index.js";
43
- import { cacheHitPercent, type TokenUsageSnapshot } from "./usage.js";
44
- import { clearLiveTurn, recordUsage } from "../../storage/sessions.js";
42
+ import type { TokenUsageSnapshot } from "./usage.js";
43
+ import {
44
+ clearLiveTurn,
45
+ recordSessionMetricEvent,
46
+ recordSessionMetrics,
47
+ recordUsage,
48
+ } from "../../storage/sessions.js";
45
49
 
46
50
  /**
47
51
  * Count one tool call. `toolName` may be raw from any backend —
@@ -49,13 +53,21 @@ import { clearLiveTurn, recordUsage } from "../../storage/sessions.js";
49
53
  * (`talon-tools-123_send`), or bare — it is normalised so every
50
54
  * backend lands on the same `tool_calls.<bare>` key.
51
55
  */
52
- export function recordToolCall(toolName: string, backend?: string): void {
53
- incrementCounter(`tool_calls.${stripMcpPrefix(toolName)}`);
54
- if (backend) incrementCounter(`backend.${backend}.tool_calls`);
56
+ export function recordToolCall(
57
+ chatId: string,
58
+ toolName: string,
59
+ backend?: string,
60
+ ): void {
61
+ recordSessionMetricEvent(chatId, {
62
+ toolCalls: 1,
63
+ toolName: stripMcpPrefix(toolName),
64
+ backend,
65
+ });
55
66
  }
56
67
 
57
68
  /** Per-turn rollup recorded once at the end of every chat turn. */
58
69
  export type TurnMetricInputs = {
70
+ chatId: string;
59
71
  /** Backend id, e.g. "claude", "codex", "kilo", "openai-agents", "opencode". */
60
72
  backend: string;
61
73
  durationMs: number;
@@ -78,45 +90,7 @@ export type TurnMetricInputs = {
78
90
  * ("queries_total")` pairs (and codex's private `codex.*` family).
79
91
  */
80
92
  export function recordTurnMetrics(inputs: TurnMetricInputs): void {
81
- recordHistogram("response_latency_ms", inputs.durationMs);
82
- recordHistogram(
83
- `backend.${inputs.backend}.response_latency_ms`,
84
- inputs.durationMs,
85
- );
86
- incrementCounter("queries_total");
87
- incrementCounter(`backend.${inputs.backend}.queries`);
88
-
89
- if (inputs.failed) incrementCounter(`backend.${inputs.backend}.turn_failed`);
90
-
91
- if (inputs.toolCalls !== undefined) {
92
- recordHistogram("tool_calls_per_turn", inputs.toolCalls);
93
- if (inputs.toolCalls > 0) incrementCounter("turns_with_tools_total");
94
- }
95
- if (inputs.apiCalls !== undefined && inputs.apiCalls > 0) {
96
- incrementCounter("api_calls_total", inputs.apiCalls);
97
- recordHistogram("api_calls_per_turn", inputs.apiCalls);
98
- }
99
-
100
- if (inputs.usage) {
101
- const u = inputs.usage;
102
- const b = `backend.${inputs.backend}`;
103
- incrementCounter("tokens.input_total", u.inputTokens);
104
- incrementCounter("tokens.output_total", u.outputTokens);
105
- incrementCounter("tokens.cache_read_total", u.cacheRead);
106
- incrementCounter("tokens.cache_write_total", u.cacheWrite);
107
- incrementCounter(`${b}.tokens.input`, u.inputTokens);
108
- incrementCounter(`${b}.tokens.output`, u.outputTokens);
109
- incrementCounter(`${b}.tokens.cache_read`, u.cacheRead);
110
- incrementCounter(`${b}.tokens.cache_write`, u.cacheWrite);
111
- // Only meaningful when the turn actually consumed input — an
112
- // all-zero snapshot (failed turn, usage-less backend) would skew
113
- // the distribution with hard zeros.
114
- if (u.inputTokens + u.cacheRead > 0) {
115
- const pct = cacheHitPercent(u);
116
- recordHistogram("cache_hit_percent", pct);
117
- recordHistogram(`${b}.cache_hit_percent`, pct);
118
- }
119
- }
93
+ recordSessionMetrics(inputs.chatId, inputs);
120
94
  }
121
95
 
122
96
  /**
@@ -152,6 +126,7 @@ export function recordFailedTurnAccounting(inputs: {
152
126
  contextWindow?: number;
153
127
  }): void {
154
128
  recordTurnMetrics({
129
+ chatId: inputs.chatId,
155
130
  backend: inputs.backend,
156
131
  durationMs: inputs.durationMs,
157
132
  toolCalls: inputs.toolCalls,
@@ -188,12 +163,12 @@ export function recordFailedTurnAccounting(inputs: {
188
163
  * reminder" unanswerable for that backend.
189
164
  */
190
165
  export function recordFlowViolation(
166
+ chatId: string,
191
167
  outcome: "retried" | "cap_exhausted",
192
168
  ): void {
193
- incrementCounter("scratchpad.trailing_text_dropped");
194
- incrementCounter(
195
- outcome === "retried"
196
- ? "scratchpad.flow_violation_retried"
197
- : "scratchpad.flow_violation_cap_exhausted",
198
- );
169
+ recordSessionMetricEvent(chatId, {
170
+ trailingTextDropped: 1,
171
+ flowViolationRetries: outcome === "retried" ? 1 : 0,
172
+ flowViolationCapExhausted: outcome === "cap_exhausted" ? 1 : 0,
173
+ });
199
174
  }
package/src/bootstrap.ts CHANGED
@@ -167,6 +167,7 @@ export async function bootstrap(
167
167
  disabledTools: config.disabledTools,
168
168
  disabledToolTags: config.disabledToolTags,
169
169
  braveApiKey: config.braveApiKey,
170
+ nativeTools: config.nativeTools,
170
171
  });
171
172
 
172
173
  initWorkspace(config.workspace);
@@ -32,6 +32,7 @@ import { skillHandlers } from "./skills.js";
32
32
  import { pluginHandlers } from "./plugins.js";
33
33
  import { modelHandlers } from "./models.js";
34
34
  import { meshHandlers } from "./mesh.js";
35
+ import { nativeHandlers } from "./native.js";
35
36
 
36
37
  // Null-prototype so a request `action` of "toString" / "constructor" / etc.
37
38
  // can't resolve an inherited Object.prototype method — `handlers[action]` only
@@ -47,6 +48,7 @@ const handlers: SharedActionHandlers = Object.assign(Object.create(null), {
47
48
  ...pluginHandlers,
48
49
  ...modelHandlers,
49
50
  ...meshHandlers,
51
+ ...nativeHandlers,
50
52
  });
51
53
 
52
54
  export async function handleSharedAction(
@@ -19,4 +19,31 @@ export const meshHandlers: SharedActionHandlers = {
19
19
  getMeshService().deviceHistory(body.device, body.hours),
20
20
  ring_device: (body) => getMeshService().ringDevice(body.device, body.message),
21
21
  get_device_status: (body) => getMeshService().getDeviceStatus(body.device),
22
+ // Exec + filesystem surface — the substrate teleport routes through.
23
+ device_exec: (body) =>
24
+ getMeshService().execOnDevice(
25
+ body.device,
26
+ body.cmd,
27
+ body.cwd,
28
+ body.timeout_sec,
29
+ ),
30
+ device_list_dir: (body) =>
31
+ getMeshService().listDirOnDevice(body.device, body.path),
32
+ device_stat: (body) => getMeshService().statOnDevice(body.device, body.path),
33
+ device_read_file: (body) =>
34
+ getMeshService().readFileFromDevice(body.device, body.path),
35
+ device_write_file: (body) =>
36
+ getMeshService().writeFileToDevice(body.device, body.path, body.content),
37
+ device_pull_file: (body) =>
38
+ getMeshService().pullFileFromDevice(
39
+ body.device,
40
+ body.remote_path,
41
+ body.local_path,
42
+ ),
43
+ device_push_file: (body) =>
44
+ getMeshService().pushFileToDevice(
45
+ body.device,
46
+ body.local_path,
47
+ body.remote_path,
48
+ ),
22
49
  };