talon-agent 1.46.0 → 1.46.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.46.0",
3
+ "version": "1.46.1",
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",
@@ -21,6 +21,13 @@ import {
21
21
  export interface HandleEventContext {
22
22
  state: StreamState;
23
23
  seenToolCallIds: Set<string>;
24
+ /**
25
+ * Tool item ids whose `item.started` was reported via [onToolStart] and
26
+ * still await their completion — the completion path must close them via
27
+ * [onToolEnd] (same id) instead of the collapsed [onToolUse] fallback.
28
+ * Owned by the caller so it spans the whole turn, like [seenToolCallIds].
29
+ */
30
+ startedToolIds: Set<string>;
24
31
  codexToolMetrics: { count: number };
25
32
  onTextBlock?: (text: string) => Promise<void>;
26
33
  onToolUse?: (
@@ -28,6 +35,16 @@ export interface HandleEventContext {
28
35
  input: Record<string, unknown>,
29
36
  meta?: { failed?: boolean },
30
37
  ) => void;
38
+ onToolStart?: (
39
+ callId: string,
40
+ toolName: string,
41
+ input: Record<string, unknown>,
42
+ ) => void;
43
+ onToolEnd?: (
44
+ callId: string,
45
+ toolName: string,
46
+ meta?: { failed?: boolean },
47
+ ) => void;
31
48
  chatId: string;
32
49
  }
33
50
 
@@ -36,12 +53,105 @@ export interface HandleEventContext {
36
53
  *
37
54
  * Synchronous — keeps the for-await loop simple. The shared
38
55
  * `routeDelivery` step at end-of-turn handles the final emit.
56
+ *
57
+ * `item.started` is observed ONLY to open the live tool window for
58
+ * consumers ([onToolStart] → UI spinner + real durations); every
59
+ * side effect that matters — metrics, terminator detection, delivered-
60
+ * text capture — stays strictly on `item.completed`, preserving the
61
+ * in_progress race fix documented on [handleMcpToolCall].
39
62
  */
40
63
  export function handleEvent(event: ThreadEvent, ctx: HandleEventContext): void {
64
+ if (event.type === "item.started") {
65
+ handleItemStarted(event.item, ctx);
66
+ return;
67
+ }
41
68
  if (event.type !== "item.completed") return;
42
69
  handleItem(event.item, ctx);
43
70
  }
44
71
 
72
+ /**
73
+ * Map a tool-shaped [ThreadItem] to its fleet-vocabulary name + input,
74
+ * or null for non-tool items (agent_message / reasoning / todo_list /
75
+ * error). Single source of truth for the started + completed paths so
76
+ * the pair always reports the same name.
77
+ */
78
+ function toolShape(
79
+ item: ThreadItem,
80
+ ): { name: string; input: Record<string, unknown> } | null {
81
+ switch (item.type) {
82
+ case "mcp_tool_call":
83
+ return {
84
+ name: item.tool,
85
+ input:
86
+ item.arguments && typeof item.arguments === "object"
87
+ ? (item.arguments as Record<string, unknown>)
88
+ : {},
89
+ };
90
+ case "command_execution":
91
+ return { name: "Bash", input: { command: item.command } };
92
+ case "file_change":
93
+ return {
94
+ name: fileChangeToolName(item.changes),
95
+ input: { changes: item.changes },
96
+ };
97
+ case "web_search":
98
+ return { name: "WebSearch", input: { query: item.query } };
99
+ case "agent_message":
100
+ case "reasoning":
101
+ case "todo_list":
102
+ case "error":
103
+ return null;
104
+ default: {
105
+ const type =
106
+ typeof (item as { type?: unknown }).type === "string"
107
+ ? (item as { type: string }).type
108
+ : "unknown";
109
+ return { name: type, input: nativeItemPayload(item) };
110
+ }
111
+ }
112
+ }
113
+
114
+ function handleItemStarted(item: ThreadItem, ctx: HandleEventContext): void {
115
+ if (!ctx.onToolStart) return;
116
+ const shape = toolShape(item);
117
+ if (!shape || ctx.startedToolIds.has(item.id)) return;
118
+ ctx.startedToolIds.add(item.id);
119
+ try {
120
+ ctx.onToolStart(item.id, shape.name, shape.input);
121
+ } catch {
122
+ /* non-fatal */
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Close the live tool window for [item] if its start was reported,
128
+ * falling back to the collapsed one-shot [onToolUse] when it wasn't
129
+ * (older CLIs that never emit `item.started`, or no start listener).
130
+ */
131
+ function reportToolTerminal(
132
+ ctx: HandleEventContext,
133
+ item: { id: string },
134
+ toolName: string,
135
+ input: Record<string, unknown>,
136
+ meta?: { failed?: boolean },
137
+ ): void {
138
+ if (ctx.startedToolIds.delete(item.id) && ctx.onToolEnd) {
139
+ try {
140
+ ctx.onToolEnd(item.id, toolName, meta);
141
+ } catch {
142
+ /* non-fatal */
143
+ }
144
+ return;
145
+ }
146
+ if (ctx.onToolUse) {
147
+ try {
148
+ ctx.onToolUse(toolName, input, meta);
149
+ } catch {
150
+ /* non-fatal */
151
+ }
152
+ }
153
+ }
154
+
45
155
  function handleItem(item: ThreadItem, ctx: HandleEventContext): void {
46
156
  switch (item.type) {
47
157
  case "agent_message":
@@ -56,7 +166,7 @@ function handleItem(item: ThreadItem, ctx: HandleEventContext): void {
56
166
  // backend instead of splitting the same activity across
57
167
  // `tool_calls.command_execution` vs `tool_calls.Bash`.
58
168
  case "command_execution":
59
- handleNativeCodexTool(ctx, "Bash", {
169
+ handleNativeCodexTool(ctx, item, "Bash", {
60
170
  command: item.command,
61
171
  status: item.status,
62
172
  ...(typeof item.exit_code === "number"
@@ -65,13 +175,13 @@ function handleItem(item: ThreadItem, ctx: HandleEventContext): void {
65
175
  });
66
176
  return;
67
177
  case "file_change":
68
- handleNativeCodexTool(ctx, fileChangeToolName(item.changes), {
178
+ handleNativeCodexTool(ctx, item, fileChangeToolName(item.changes), {
69
179
  status: item.status,
70
180
  changes: item.changes,
71
181
  });
72
182
  return;
73
183
  case "web_search":
74
- handleNativeCodexTool(ctx, "WebSearch", { query: item.query });
184
+ handleNativeCodexTool(ctx, item, "WebSearch", { query: item.query });
75
185
  return;
76
186
  case "reasoning":
77
187
  case "todo_list":
@@ -88,7 +198,7 @@ function handleItem(item: ThreadItem, ctx: HandleEventContext): void {
88
198
  typeof (item as { type?: unknown }).type === "string"
89
199
  ? (item as { type: string }).type
90
200
  : "unknown";
91
- handleNativeCodexTool(ctx, type, nativeItemPayload(item));
201
+ handleNativeCodexTool(ctx, item, type, nativeItemPayload(item));
92
202
  return;
93
203
  }
94
204
  }
@@ -150,25 +260,12 @@ function handleMcpToolCall(
150
260
  // nothing was delivered and the turn is not terminated, so skip
151
261
  // the stream-state mutations (delivered-text capture / terminator
152
262
  // flip) that assume a successful call.
153
- if (ctx.onToolUse) {
154
- try {
155
- ctx.onToolUse(toolName, input, { failed: true });
156
- } catch {
157
- /* non-fatal */
158
- }
159
- }
263
+ reportToolTerminal(ctx, item, toolName, input, { failed: true });
160
264
  return;
161
265
  }
162
266
 
163
267
  recordToolUse(ctx.state, toolName, input);
164
-
165
- if (ctx.onToolUse) {
166
- try {
167
- ctx.onToolUse(toolName, input);
168
- } catch {
169
- /* non-fatal */
170
- }
171
- }
268
+ reportToolTerminal(ctx, item, toolName, input);
172
269
 
173
270
  if (!ctx.state.turnTerminated && isTurnTerminator(toolName, input)) {
174
271
  ctx.state.turnTerminated = true;
@@ -192,17 +289,12 @@ function recordCodexToolMetric(
192
289
 
193
290
  function handleNativeCodexTool(
194
291
  ctx: HandleEventContext,
292
+ item: { id: string },
195
293
  toolName: string,
196
294
  input: Record<string, unknown>,
197
295
  ): void {
198
296
  recordCodexToolMetric(ctx, toolName);
199
- if (ctx.onToolUse) {
200
- try {
201
- ctx.onToolUse(toolName, input);
202
- } catch {
203
- /* non-fatal */
204
- }
205
- }
297
+ reportToolTerminal(ctx, item, toolName, input);
206
298
  }
207
299
 
208
300
  /**
@@ -203,6 +203,8 @@ export async function handleMessage(
203
203
  messageId,
204
204
  onTextBlock,
205
205
  onToolUse,
206
+ onToolStart,
207
+ onToolEnd,
206
208
  } = params;
207
209
  const t0 = Date.now();
208
210
  const session = getSession(chatId);
@@ -316,6 +318,7 @@ export async function handleMessage(
316
318
  // into the live-turn overlay — /status updates while the turn runs.
317
319
  const streamState = createStreamState(chatId);
318
320
  const seenToolCallIds = new Set<string>();
321
+ const startedToolIds = new Set<string>();
319
322
  const codexToolMetrics = { count: 0 };
320
323
  const abortController = new AbortController();
321
324
  activeAborts.set(chatId, abortController);
@@ -440,9 +443,12 @@ export async function handleMessage(
440
443
  handleEvent(event, {
441
444
  state: streamState,
442
445
  seenToolCallIds,
446
+ startedToolIds,
443
447
  codexToolMetrics,
444
448
  onTextBlock,
445
449
  onToolUse,
450
+ onToolStart,
451
+ onToolEnd,
446
452
  chatId,
447
453
  });
448
454
 
@@ -63,6 +63,12 @@ export async function* handlerToEvents(
63
63
  r?.();
64
64
  };
65
65
 
66
+ // Tool calls announced via `onToolStart` that have not yet reported an
67
+ // `onToolEnd` — flushed with an error result if the handler settles with
68
+ // any still open (abort/crash mid-tool), preserving the call→result
69
+ // pairing contract for every consumer.
70
+ const openToolCalls = new Map<string, string>();
71
+
66
72
  // Handler `onStreamDelta` delivers the FULL accumulated text so far,
67
73
  // not the new chunk. `AgentEvent.text_delta.text` carries the delta —
68
74
  // event-native consumers (the frontends, via the dispatcher's
@@ -109,7 +115,7 @@ export async function* handlerToEvents(
109
115
  .toString(36)
110
116
  .slice(2, 8)}`;
111
117
  emit({ type: "tool_call", id, name: toolName, input });
112
- // Callback backends (Codex, OpenCode, Kilo, OpenAI Agents) surface a
118
+ // Callback backends (OpenCode, Kilo, OpenAI Agents) surface a
113
119
  // tool call only at terminal status — by the time onToolUse fires,
114
120
  // the tool has already finished. Emit the matching `tool_result`
115
121
  // immediately so consumers see the same call→result contract the
@@ -123,6 +129,23 @@ export async function* handlerToEvents(
123
129
  ...(meta?.failed ? { error: "tool call failed" } : {}),
124
130
  });
125
131
  },
132
+ // Live tool lifecycle (Codex `item.started` → `item.completed`):
133
+ // `tool_call` goes out the moment the SDK dispatches the tool and the
134
+ // matching `tool_result` when it settles, so consumers see a real
135
+ // running window instead of the collapsed 0ms call+result above.
136
+ onToolStart: (callId, toolName, input) => {
137
+ openToolCalls.set(callId, toolName);
138
+ emit({ type: "tool_call", id: callId, name: toolName, input });
139
+ },
140
+ onToolEnd: (callId, toolName, meta) => {
141
+ if (!openToolCalls.delete(callId)) return; // unknown/duplicate id
142
+ emit({
143
+ type: "tool_result",
144
+ id: callId,
145
+ name: toolName,
146
+ ...(meta?.failed ? { error: "tool call failed" } : {}),
147
+ });
148
+ },
126
149
  };
127
150
 
128
151
  let result: QueryResult | undefined;
@@ -158,6 +181,13 @@ export async function* handlerToEvents(
158
181
  }
159
182
  await handlerPromise;
160
183
 
184
+ // Resolve any tool that started but never settled (abort or crash killed
185
+ // the subprocess mid-call) so spinners close instead of hanging forever.
186
+ for (const [id, name] of openToolCalls) {
187
+ yield { type: "tool_result", id, name, error: "tool did not complete" };
188
+ }
189
+ openToolCalls.clear();
190
+
161
191
  if (error) {
162
192
  yield { type: "error", error: classifiedToAgentError(classify(error)) };
163
193
  return;
@@ -48,12 +48,34 @@ export type QueryParams = {
48
48
  * their SDKs surface it at (or after) terminal status, with no separate
49
49
  * start/finish pair. `meta.failed` marks a call whose terminal status was
50
50
  * an error, so consumers can render it as failed rather than successful.
51
+ *
52
+ * Backends whose SDK exposes a live start signal should prefer the
53
+ * `onToolStart` / `onToolEnd` pair below and only fall back to this
54
+ * for calls whose start was never observed.
51
55
  */
52
56
  onToolUse?: (
53
57
  toolName: string,
54
58
  input: Record<string, unknown>,
55
59
  meta?: { failed?: boolean },
56
60
  ) => void;
61
+ /**
62
+ * Live tool lifecycle (backends with start signals — Codex emits
63
+ * `item.started`). `callId` is the SDK's stable item id: the same id
64
+ * must be passed to `onToolEnd` so consumers can pair the two and
65
+ * measure a real duration. Without this pair, `onToolUse` collapses
66
+ * call+result into one instant and every tool renders as 0ms.
67
+ */
68
+ onToolStart?: (
69
+ callId: string,
70
+ toolName: string,
71
+ input: Record<string, unknown>,
72
+ ) => void;
73
+ /** Terminal counterpart to [onToolStart]; ignored for unknown ids. */
74
+ onToolEnd?: (
75
+ callId: string,
76
+ toolName: string,
77
+ meta?: { failed?: boolean },
78
+ ) => void;
57
79
  };
58
80
 
59
81
  /** Result of a backend AI query. */