talon-agent 1.9.2 → 1.10.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.
@@ -24,6 +24,7 @@ import { log, logError, logWarn } from "../../util/log.js";
24
24
  import { traceMessage } from "../../util/trace.js";
25
25
  import { incrementCounter, recordHistogram } from "../../util/metrics.js";
26
26
  import { formatFullDatetime } from "../../util/time.js";
27
+ import { isTurnTerminator, stripMcpPrefix } from "../../core/tools/index.js";
27
28
 
28
29
  import type { Query } from "@anthropic-ai/claude-agent-sdk";
29
30
  import type { QueryParams, QueryResult } from "../../core/types.js";
@@ -38,6 +39,8 @@ import {
38
39
  processStreamDelta,
39
40
  processAssistantMessage,
40
41
  processResultMessage,
42
+ normalizeForDedupe,
43
+ isDuplicateOfDelivered,
41
44
  } from "./stream.js";
42
45
 
43
46
  // ── Active query store ──────────────────────────────────────────────────────
@@ -92,6 +95,35 @@ export async function handleMessage(
92
95
  activeQueries.set(chatId, qi);
93
96
  const state = createStreamState();
94
97
 
98
+ // Capture text args from delivery tools (`end_turn`, `send(type="text")`)
99
+ // so the end-of-turn trailing-text fallback can dedupe against content
100
+ // already delivered. Without this, a model that writes prose AND calls a
101
+ // delivery tool with similar text would surface twice in the chat.
102
+ //
103
+ // Tool names arrive MCP-prefixed (e.g. `mcp__telegram-tools__end_turn`)
104
+ // when routed through MCP — strip the prefix so equality checks match
105
+ // the registry's bare names.
106
+ const captureDeliveredText = (
107
+ toolName: string,
108
+ input: Record<string, unknown>,
109
+ ): void => {
110
+ const bareName = stripMcpPrefix(toolName);
111
+ let deliveredText: string | undefined;
112
+ if (bareName === "end_turn" && typeof input.text === "string") {
113
+ deliveredText = input.text;
114
+ } else if (
115
+ bareName === "send" &&
116
+ input.type === "text" &&
117
+ typeof input.text === "string"
118
+ ) {
119
+ deliveredText = input.text;
120
+ }
121
+ if (deliveredText) {
122
+ const norm = normalizeForDedupe(deliveredText);
123
+ if (norm) state.deliveredTextNorms.push(norm);
124
+ }
125
+ };
126
+
95
127
  try {
96
128
  for await (const message of qi) {
97
129
  // Session ID capture
@@ -110,9 +142,18 @@ export async function handleMessage(
110
142
  if (isAssistant(message)) {
111
143
  const result = processAssistantMessage(message, state);
112
144
 
113
- // Notify tool usage
145
+ // Track the trailing text from this assistant message. Multiple
146
+ // assistant messages can fire per turn (one per tool-use round-trip);
147
+ // only the LAST one's trailingText is the user-facing final reply.
148
+ state.lastTrailingText = result.trailingText;
149
+
150
+ // Notify tool usage + capture delivery-tool text for end-of-turn dedup
114
151
  for (const tool of result.tools) {
115
152
  incrementCounter(`tool_calls.${tool.name}`);
153
+ captureDeliveredText(tool.name, tool.input);
154
+ if (isTurnTerminator(tool.name)) {
155
+ state.turnTerminated = true;
156
+ }
116
157
  if (onToolUse) {
117
158
  try {
118
159
  onToolUse(tool.name, tool.input);
@@ -132,6 +173,25 @@ export async function handleMessage(
132
173
  }
133
174
  }
134
175
  }
176
+
177
+ // Note: we previously called `qi.interrupt()` here when a turn-
178
+ // terminator tool fired, intending to short-circuit the SDK's
179
+ // wasted "wrap up after end_turn tool_result" follow-up API call
180
+ // (~3s of phantom typing while the model says nothing useful).
181
+ // That interrupt races with in-flight MCP tool dispatches in the
182
+ // same assistant message — `end_turn` itself is an MCP tool, and
183
+ // the model frequently emits sibling tool_use blocks in the same
184
+ // message. interrupt cancels their AbortController mid-flight,
185
+ // which surfaces as `MCP error -32001: AbortError` in the SDK
186
+ // result and bubbles up to the user as "Something went wrong".
187
+ //
188
+ // The natural-close path is fine: the SDK does one more API call
189
+ // after end_turn returns (the model has nothing to say so it
190
+ // returns a stop turn quickly, ~2-3s typing lag), then yields a
191
+ // result message and exits the iterator cleanly. We accept the
192
+ // typing lag in exchange for not breaking turns. `state.turnTerminated`
193
+ // is still tracked so the flow-violation re-prompt path below can
194
+ // skip its retry when the model explicitly ended its turn.
135
195
  continue;
136
196
  }
137
197
 
@@ -224,6 +284,55 @@ export async function handleMessage(
224
284
  }
225
285
  }
226
286
 
287
+ // ── Trailing-prose contract + flow-violation retry ──────────────────────
288
+ // The output stream is private scratchpad by design. Final replies must go
289
+ // through `end_turn` (canonical) or `send` (mid-turn rich content). When a
290
+ // turn ends with no tool call AND no trailing prose, that's valid silent
291
+ // close (model only reacted, or had nothing to do). When the model wrote
292
+ // prose but didn't route it through a delivery tool, that's a flow
293
+ // violation — the prose is private scratchpad, dropped from the user's
294
+ // view. To prevent these from going unnoticed, we re-prompt the model
295
+ // ONCE with a synthetic system message in the same session: it sees its
296
+ // broken turn in history + a reminder of the contract, and gets a fresh
297
+ // turn to deliver via end_turn. If it violates again on the retry, we
298
+ // give up loudly and accept the silent drop.
299
+ //
300
+ // Exception: if a turn-terminator tool (e.g. end_turn) was called, the
301
+ // model explicitly declared "I'm done" — respect it. Any trailing prose
302
+ // that slipped in earlier in the same assistant message gets logged but
303
+ // does NOT re-prompt (would loop endlessly with a model that pairs prose
304
+ // with end_turn).
305
+ const trailing = state.lastTrailingText.trim();
306
+ const flowViolation =
307
+ trailing.length > 0 &&
308
+ !state.turnTerminated &&
309
+ !isDuplicateOfDelivered(trailing, state.deliveredTextNorms);
310
+
311
+ if (flowViolation) {
312
+ incrementCounter("scratchpad.trailing_text_dropped");
313
+ log(
314
+ "agent",
315
+ `[${chatId}] flow violation: trailing prose (${trailing.length} chars) without end_turn/send. ${
316
+ _retried
317
+ ? "Already retried — accepting silent drop."
318
+ : "Re-prompting with reminder."
319
+ }`,
320
+ );
321
+
322
+ if (!_retried) {
323
+ incrementCounter("scratchpad.flow_violation_retried");
324
+ const reminder =
325
+ "[FLOW VIOLATION] You produced text content but didn't call `end_turn` or `send`. " +
326
+ "Pure prose in your output stream is private scratchpad — it's dropped, the user " +
327
+ "never sees it. Please retry with the proper flow: " +
328
+ "`end_turn(text=...)` to deliver a final reply, " +
329
+ "`end_turn()` (no args) to close silently, or " +
330
+ "`send(...)` for mid-turn rich content (photos, polls, etc.). " +
331
+ "Respond now using the correct tool call.";
332
+ return handleMessage({ ...params, text: reminder }, true);
333
+ }
334
+ }
335
+
227
336
  // ── Build result ──────────────────────────────────────────────────────────
228
337
 
229
338
  state.allResponseText += state.currentBlockText;
@@ -6,12 +6,19 @@
6
6
  */
7
7
 
8
8
  import { resolve } from "node:path";
9
- import type { Options } from "@anthropic-ai/claude-agent-sdk";
9
+ import type {
10
+ Options,
11
+ PostToolBatchHookInput,
12
+ HookCallback,
13
+ HookJSONOutput,
14
+ } from "@anthropic-ai/claude-agent-sdk";
10
15
  import { getSession } from "../../storage/sessions.js";
11
16
  import { getChatSettings } from "../../storage/chat-settings.js";
12
17
  import { getPluginMcpServers } from "../../core/plugin.js";
13
18
  import { resolveModelId } from "../../core/models.js";
14
19
  import { wrapMcpServer } from "../../util/mcp-launcher.js";
20
+ import { isTurnTerminator } from "../../core/tools/index.js";
21
+ import { log } from "../../util/log.js";
15
22
  import { getConfig, getBridgePort } from "./state.js";
16
23
  import { DISALLOWED_TOOLS_CHAT, EFFORT_MAP } from "./constants.js";
17
24
 
@@ -90,6 +97,54 @@ export function buildMcpServers(
90
97
  return servers;
91
98
  }
92
99
 
100
+ // ── Hooks ───────────────────────────────────────────────────────────────────
101
+
102
+ /**
103
+ * PostToolBatch hook: terminate the SDK query loop the moment a turn-terminator
104
+ * tool (e.g. `end_turn`) resolves in the assistant's tool batch.
105
+ *
106
+ * Why PostToolBatch and not PostToolUse:
107
+ * - PostToolUse fires per-tool and may run concurrently for parallel tool
108
+ * calls. Returning `continue: false` from there can race with sibling MCP
109
+ * tools whose AbortControllers haven't yet completed — the same race that
110
+ * killed the previous `qi.interrupt()` approach (see handler.ts comment
111
+ * and commit `d5ce30f`).
112
+ * - PostToolBatch fires exactly ONCE after every tool in the batch has
113
+ * resolved. By definition there are no in-flight siblings to race with.
114
+ *
115
+ * What this saves:
116
+ * - The ~2-3s "phantom typing" round-trip the SDK makes after `end_turn`
117
+ * returns (the model has nothing to say, generates a stop_turn anyway).
118
+ * - Trailing prose that gets generated during that round-trip and was
119
+ * previously suppressed only at the delivery layer (real tokens spent).
120
+ *
121
+ * Returns `{ continue: false, stopReason: ... }` → SDK exits with TerminalReason
122
+ * `'hook_stopped'`, no further model generation.
123
+ */
124
+ const turnTerminatorHook: HookCallback = async (
125
+ input,
126
+ ): Promise<HookJSONOutput> => {
127
+ if (input.hook_event_name !== "PostToolBatch") {
128
+ return { continue: true };
129
+ }
130
+ const batch = input as PostToolBatchHookInput;
131
+ const terminator = batch.tool_calls.find((tc) =>
132
+ isTurnTerminator(tc.tool_name),
133
+ );
134
+ if (terminator) {
135
+ log(
136
+ "agent",
137
+ `PostToolBatch: terminating SDK loop on ${terminator.tool_name} ` +
138
+ `(batch size: ${batch.tool_calls.length})`,
139
+ );
140
+ return {
141
+ continue: false,
142
+ stopReason: "turn terminated by end_turn / send",
143
+ };
144
+ }
145
+ return { continue: true };
146
+ };
147
+
93
148
  // ── Options builder ─────────────────────────────────────────────────────────
94
149
 
95
150
  export function buildSdkOptions(chatId: string): BuildSdkOptionsResult {
@@ -120,6 +175,9 @@ export function buildSdkOptions(chatId: string): BuildSdkOptionsResult {
120
175
  ...buildMcpServers(chatId),
121
176
  ...getPluginMcpServers(`http://127.0.0.1:${getBridgePort()}`, chatId),
122
177
  },
178
+ hooks: {
179
+ PostToolBatch: [{ hooks: [turnTerminatorHook] }],
180
+ },
123
181
  ...(session.sessionId ? { resume: session.sessionId } : {}),
124
182
  };
125
183
 
@@ -34,6 +34,33 @@ export type StreamState = {
34
34
  sdkCacheRead: number;
35
35
  sdkCacheWrite: number;
36
36
  lastStreamUpdate: number;
37
+ /**
38
+ * Trailing text from the most recent assistant message — text after all
39
+ * tool_use blocks (or the full text when no tools were called). NOT
40
+ * delivered to the user (the output stream is private scratchpad by
41
+ * contract). Tracked so the handler can log a diagnostic when the model
42
+ * wrote prose without routing it through `end_turn` / `send` — surfaces
43
+ * missed end_turn calls in metrics rather than silently dropping content.
44
+ */
45
+ lastTrailingText: string;
46
+ /**
47
+ * Normalized text args observed on `end_turn` / `send(type="text")` tool
48
+ * calls during this turn. Cross-tool dedup: if both fire with similar
49
+ * content (e.g. model calls both with the same text mid-turn), the
50
+ * second one can be matched against this list to avoid the user seeing
51
+ * the same message twice. Also used to silence the trailing-prose
52
+ * diagnostic when the prose just duplicates what was already delivered.
53
+ */
54
+ deliveredTextNorms: string[];
55
+ /**
56
+ * Set when a tool with `endsTurn: true` (e.g. `end_turn`) was observed
57
+ * in this turn. Once true, the handler invokes `qi.interrupt()` to abort
58
+ * the SDK loop cleanly — the model can't produce more trailing scratchpad
59
+ * after this point. Also gates the flow-violation re-prompt: if the model
60
+ * explicitly ended its turn, we don't re-prompt for trailing prose that
61
+ * may have appeared in the same assistant message before the terminator.
62
+ */
63
+ turnTerminated: boolean;
37
64
  };
38
65
 
39
66
  export function createStreamState(): StreamState {
@@ -50,6 +77,9 @@ export function createStreamState(): StreamState {
50
77
  sdkCacheRead: 0,
51
78
  sdkCacheWrite: 0,
52
79
  lastStreamUpdate: 0,
80
+ lastTrailingText: "",
81
+ deliveredTextNorms: [],
82
+ turnTerminated: false,
53
83
  };
54
84
  }
55
85
 
@@ -224,3 +254,40 @@ export function processResultMessage(
224
254
  state.currentBlockText = msg.result;
225
255
  }
226
256
  }
257
+
258
+ // ── Trailing-text fallback dedup ────────────────────────────────────────────
259
+
260
+ /**
261
+ * Normalize text for fuzzy comparison — trim, lowercase, collapse whitespace,
262
+ * strip emoji. Used to detect whether trailing prose duplicates content
263
+ * already delivered via `end_turn` / `send(type="text")`.
264
+ */
265
+ export function normalizeForDedupe(text: string): string {
266
+ return text
267
+ .trim()
268
+ .toLowerCase()
269
+ .replace(/\p{Emoji_Presentation}|\p{Extended_Pictographic}/gu, "")
270
+ .replace(/\s+/g, " ")
271
+ .trim();
272
+ }
273
+
274
+ const MIN_DEDUP_LENGTH = 10;
275
+
276
+ /**
277
+ * Returns true if `candidate` is substantively the same as any text in
278
+ * `deliveredNorms`. "Substantively" = one is a substring of the other after
279
+ * normalization; both must be at least MIN_DEDUP_LENGTH chars to avoid
280
+ * dropping short legitimate replies.
281
+ */
282
+ export function isDuplicateOfDelivered(
283
+ candidate: string,
284
+ deliveredNorms: string[],
285
+ ): boolean {
286
+ if (deliveredNorms.length === 0) return false;
287
+ const norm = normalizeForDedupe(candidate);
288
+ if (norm.length < MIN_DEDUP_LENGTH) return false;
289
+ return deliveredNorms.some(
290
+ (d) =>
291
+ d.length >= MIN_DEDUP_LENGTH && (norm.includes(d) || d.includes(norm)),
292
+ );
293
+ }
@@ -30,6 +30,47 @@ export const ALL_TOOLS: readonly ToolDefinition[] = [
30
30
  ...adminTools,
31
31
  ];
32
32
 
33
+ /**
34
+ * Names of tools that explicitly terminate the model's turn.
35
+ *
36
+ * Backend handlers consume this set to abort their stream loop after
37
+ * observing one of these tools — without it, the model can keep producing
38
+ * trailing scratchpad prose after declaring "I'm done", which trips the
39
+ * flow-violation re-prompt path. Declaration is on the tool definition
40
+ * (`endsTurn: true`); detection is shared; abort is backend-specific.
41
+ */
42
+ const TURN_TERMINATOR_NAMES: ReadonlySet<string> = new Set(
43
+ ALL_TOOLS.filter((t) => t.endsTurn).map((t) => t.name),
44
+ );
45
+
46
+ /**
47
+ * Strip an MCP server prefix (`mcp__<server>__`) from a tool name.
48
+ *
49
+ * Tools served through MCP arrive at the SDK with the prefix attached
50
+ * (e.g. `mcp__telegram-tools__end_turn`), while the registry stores them
51
+ * by their bare name (`end_turn`). Callers that want to compare against
52
+ * the registry should normalize first.
53
+ *
54
+ * Returns the input unchanged if no prefix matches — safe to call on any
55
+ * tool name. The non-greedy `.+?` matches the FIRST `__` boundary after
56
+ * `mcp__`, which is the server-name terminator in MCP's naming scheme.
57
+ */
58
+ export function stripMcpPrefix(toolName: string): string {
59
+ return toolName.replace(/^mcp__.+?__/, "");
60
+ }
61
+
62
+ /**
63
+ * Whether a tool call by this name should terminate the model's turn.
64
+ *
65
+ * Accepts both bare names (`end_turn`) and MCP-prefixed names
66
+ * (`mcp__telegram-tools__end_turn`) — the prefix is stripped before
67
+ * comparing against the terminator set.
68
+ */
69
+ export function isTurnTerminator(toolName: string): boolean {
70
+ if (TURN_TERMINATOR_NAMES.has(toolName)) return true;
71
+ return TURN_TERMINATOR_NAMES.has(stripMcpPrefix(toolName));
72
+ }
73
+
33
74
  /** Filter options for composing a tool set. */
34
75
  export interface ComposeOptions {
35
76
  /** Include only tools available on this frontend. */
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Messaging tools — send, react, edit, delete, forward, pin/unpin, stop poll.
2
+ * Messaging tools — send, end_turn, react, edit, delete, forward, pin/unpin,
3
+ * stop poll.
3
4
  */
4
5
 
5
6
  import { z } from "zod";
@@ -7,6 +8,83 @@ import type { ToolDefinition } from "./types.js";
7
8
  import { idSchema } from "./schemas.js";
8
9
 
9
10
  export const messagingTools: ToolDefinition[] = [
11
+ // ── end_turn — explicit final-reply delivery ──────────────────────────
12
+ // Schema-typed alternative to relying on a trailing-text fallback. The
13
+ // model is taught that this is the canonical way to deliver its final
14
+ // reply. Functionally a thin wrapper over send(type="text") + reply_to +
15
+ // buttons; the value is in the EXPLICIT semantic ("this ends my turn")
16
+ // and that the model sees a single tool whose purpose is unambiguous.
17
+ //
18
+ // The output stream is private scratchpad by contract. If the model
19
+ // writes trailing prose without calling end_turn or send, the handler
20
+ // re-prompts ONCE in the same session with a flow-violation reminder
21
+ // so the model can retry properly. Persistent violation after retry
22
+ // results in silent drop + `scratchpad.trailing_text_dropped` counter.
23
+ // `end_turn` is the documented happy path.
24
+ {
25
+ name: "end_turn",
26
+ description: `End your current turn and deliver your final reply to the user. This is the canonical way to respond.
27
+
28
+ Call this AT MOST ONCE per turn — it should be the last tool you call. Behaves like send(type="text") with reply_to and buttons support, but the name makes the intent explicit: this is the message that ends the turn.
29
+
30
+ Examples:
31
+ end_turn(text="Got it sur") — plain reply
32
+ end_turn(text="On it", reply_to=12345) — reply to a specific message ID
33
+ end_turn(text="Pick one", buttons=[[{"text":"A","callback_data":"a"}]]) — with buttons
34
+ end_turn() — silent end (no message; useful when you already replied via earlier send/react calls)
35
+
36
+ Notes:
37
+ - For richer message types (photos, polls, voice, scheduled messages, multi-target), use the send tool — those don't fit "final reply" semantics.
38
+ - The output stream is private scratchpad. If you write prose without calling end_turn or send, the handler re-prompts you ONCE with a flow-violation reminder so you can retry properly. Persistent violation drops the prose silently. end_turn is the documented happy path.`,
39
+ schema: {
40
+ text: z
41
+ .string()
42
+ .optional()
43
+ .describe(
44
+ "Final reply text. Supports Markdown. Omit to end the turn silently (no message sent).",
45
+ ),
46
+ reply_to: idSchema
47
+ .optional()
48
+ .describe("Message ID to reply to (typically the user's [msg_id:N])"),
49
+ buttons: z
50
+ .array(
51
+ z.array(
52
+ z.object({
53
+ text: z.string(),
54
+ url: z.string().optional(),
55
+ callback_data: z.string().optional(),
56
+ }),
57
+ ),
58
+ )
59
+ .optional()
60
+ .describe("Inline keyboard button rows"),
61
+ },
62
+ execute: async (params, bridge) => {
63
+ // Telegram path: routes to the same bridge actions as send(type="text")
64
+ // so bridgeMessageCount, dedup, and audit logging all stay consistent.
65
+ if (typeof params.text !== "string" || params.text.trim() === "") {
66
+ // Silent end — no bridge call. The handler still sees the tool was
67
+ // invoked (via deliveredTextNorms staying empty), and trailing-text
68
+ // fallback won't fire because there was no trailing prose.
69
+ return { ok: true, silent: true };
70
+ }
71
+ if (params.buttons) {
72
+ return bridge("send_message_with_buttons", {
73
+ text: params.text,
74
+ rows: params.buttons,
75
+ reply_to_message_id: params.reply_to,
76
+ });
77
+ }
78
+ return bridge("send_message", {
79
+ text: params.text,
80
+ reply_to_message_id: params.reply_to,
81
+ });
82
+ },
83
+ frontends: ["telegram", "teams"],
84
+ tag: "messaging",
85
+ endsTurn: true,
86
+ },
87
+
10
88
  // ── Telegram unified send ─────────────────────────────────────────────
11
89
  {
12
90
  name: "send",
@@ -58,4 +58,18 @@ export interface ToolDefinition {
58
58
 
59
59
  /** Grouping tag. */
60
60
  readonly tag: ToolTag;
61
+
62
+ /**
63
+ * This tool explicitly ends the model's turn. Backend handlers observe
64
+ * this flag to abort their stream loop cleanly after the tool's bridge
65
+ * call completes — without it, the model is free to keep producing
66
+ * trailing prose into private scratchpad after declaring "I'm done",
67
+ * which then trips the flow-violation re-prompt path. With this flag,
68
+ * an end_turn call genuinely ends the turn.
69
+ *
70
+ * Backend abort mechanism is backend-specific (Claude SDK uses
71
+ * Query.interrupt(); other backends manage their own loop) — this flag
72
+ * is the shared declarative signal, not the implementation.
73
+ */
74
+ readonly endsTurn?: boolean;
61
75
  }
@@ -14,7 +14,7 @@ import type { Gateway } from "../../core/gateway.js";
14
14
  import { log, logError } from "../../util/log.js";
15
15
  import { deriveNumericChatId } from "../../util/chat-id.js";
16
16
  import { resolveModel } from "../../core/models.js";
17
- import { createTeamsActionHandler } from "./actions.js";
17
+ import { createTeamsActionHandler, postToTeams } from "./actions.js";
18
18
  import { splitTeamsMessage, buildAdaptiveCard } from "./formatting.js";
19
19
  import {
20
20
  initGraphClient,
@@ -356,18 +356,28 @@ export function createTeamsFrontend(
356
356
  ` tool: ${toolName}${detail ? ` — ${String(detail).slice(0, 100)}` : ""}`,
357
357
  );
358
358
  },
359
- })
360
- .then(async (result) => {
361
- // Only deliver messages sent via the send_message tool.
362
- // Do NOT send fallback text if the model chose not to use send_message,
363
- // it's either choosing not to respond or outputting internal reasoning
364
- // that shouldn't be shown to users.
365
- if (result.bridgeMessageCount === 0 && result.text?.trim()) {
366
- log(
359
+ // Deliver assistant text (progress text before tool calls AND
360
+ // the end-of-turn trailing-text fallback) to the Teams chat.
361
+ // Without this, prose-only assistant turns would be silently
362
+ // droppedsame scratchpad bug Telegram hit.
363
+ onTextBlock: async (blockText) => {
364
+ if (!blockText.trim()) return;
365
+ try {
366
+ await postToTeams(webhookUrl, blockText);
367
+ gateway.incrementMessages(numericChatId);
368
+ } catch (err) {
369
+ logError(
367
370
  "teams",
368
- `Suppressed fallback text (${result.text.length} chars) no send_message tool used`,
371
+ `onTextBlock postToTeams failed: ${err instanceof Error ? err.message : err}`,
369
372
  );
370
373
  }
374
+ },
375
+ })
376
+ .then(async (_result) => {
377
+ // No fallback delivery — turns without end_turn / send_message
378
+ // are intentional silent ends. Trailing prose without a tool
379
+ // call is scratchpad and dropped; the SDK handler emits a
380
+ // `scratchpad.trailing_text_dropped` metric on those.
371
381
  })
372
382
  .catch((err) => {
373
383
  logError(
@@ -14,6 +14,7 @@ import {
14
14
  appendDailyLog,
15
15
  appendDailyLogResponse,
16
16
  } from "../../storage/daily-log.js";
17
+ import { stripMcpPrefix } from "../../core/tools/index.js";
17
18
  import { setMessageFilePath } from "../../storage/history.js";
18
19
  import { addMedia } from "../../storage/media-index.js";
19
20
  import { recordMessageProcessed, recordError } from "../../util/watchdog.js";
@@ -771,8 +772,17 @@ async function processAndReply(params: ProcessAndReplyParams): Promise<void> {
771
772
  onStreamDelta,
772
773
  onTextBlock,
773
774
  onToolUse: (toolName, input) => {
774
- if (
775
- toolName === "send" &&
775
+ // Tool names arrive MCP-prefixed (e.g. `mcp__telegram-tools__end_turn`)
776
+ // when routed through MCP — strip the prefix so equality checks
777
+ // match the registry's bare names. Both `end_turn(text=...)` and
778
+ // `send(type="text")` are user-facing text deliveries; capture
779
+ // both so the daily log records bot responses regardless of which
780
+ // delivery tool the model used.
781
+ const bareName = stripMcpPrefix(toolName);
782
+ if (bareName === "end_turn" && typeof input.text === "string") {
783
+ appendDailyLogResponse("Talon", input.text, { chatTitle });
784
+ } else if (
785
+ bareName === "send" &&
776
786
  input.type === "text" &&
777
787
  typeof input.text === "string"
778
788
  ) {
@@ -781,16 +791,10 @@ async function processAndReply(params: ProcessAndReplyParams): Promise<void> {
781
791
  },
782
792
  });
783
793
 
784
- if (
785
- result.bridgeMessageCount === 0 &&
786
- !stream.sentTextBlock &&
787
- result.text?.trim()
788
- ) {
789
- log(
790
- "bot",
791
- `Suppressed fallback text (${result.text.length} chars) — no send tool used`,
792
- );
793
- }
794
+ // No fallback delivery — turns that don't call `end_turn` / `send` are
795
+ // intentional silent ends. Trailing prose written without a tool call is
796
+ // scratchpad and dropped (the SDK handler logs a `scratchpad.trailing_
797
+ // text_dropped` metric so missed end_turn calls show up in counters).
794
798
  } finally {
795
799
  clearTimeout(streamTimer);
796
800
  }