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.
- package/package.json +10 -5
- package/prompts/telegram.md +24 -6
- package/src/__tests__/claude-sdk-options.test.ts +95 -0
- package/src/__tests__/end-turn.test.ts +307 -0
- package/src/__tests__/handlers.test.ts +107 -43
- package/src/__tests__/integration/sdk-stub.test.ts +208 -0
- package/src/__tests__/integration/stub-claude/build-sea.mjs +114 -0
- package/src/__tests__/integration/stub-claude/fake-claude.mjs +352 -0
- package/src/__tests__/integration/stub-claude/helpers.ts +263 -0
- package/src/__tests__/integration/stub-claude/protocol.ts +108 -0
- package/src/__tests__/integration/stub-claude/sea-config.json +7 -0
- package/src/__tests__/integration/talon-bootstrap.ts +206 -0
- package/src/__tests__/integration/talon-functional.test.ts +190 -0
- package/src/__tests__/package.functional.test.ts +178 -0
- package/src/backend/claude-sdk/handler.ts +110 -1
- package/src/backend/claude-sdk/options.ts +59 -1
- package/src/backend/claude-sdk/stream.ts +67 -0
- package/src/core/tools/index.ts +41 -0
- package/src/core/tools/messaging.ts +79 -1
- package/src/core/tools/types.ts +14 -0
- package/src/frontend/teams/index.ts +20 -10
- package/src/frontend/telegram/handlers.ts +16 -12
|
@@ -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
|
-
//
|
|
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 {
|
|
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
|
+
}
|
package/src/core/tools/index.ts
CHANGED
|
@@ -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,
|
|
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",
|
package/src/core/tools/types.ts
CHANGED
|
@@ -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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
+
// dropped — same 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
|
-
`
|
|
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
|
-
|
|
775
|
-
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
}
|