mixdog 0.9.1 → 0.9.2
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 +8 -1
- package/scripts/_bench-cwc.json +20 -0
- package/scripts/agent-loop-policy-test.mjs +37 -0
- package/scripts/agent-parallel-smoke.mjs +54 -10
- package/scripts/background-task-meta-smoke.mjs +1 -1
- package/scripts/bench-run.mjs +262 -0
- package/scripts/compact-smoke.mjs +12 -0
- package/scripts/compact-trigger-migration-smoke.mjs +67 -1
- package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
- package/scripts/internal-comms-bench.mjs +727 -0
- package/scripts/internal-comms-smoke.mjs +75 -0
- package/scripts/lead-workflow-smoke.mjs +4 -4
- package/scripts/live-worker-smoke.mjs +9 -9
- package/scripts/output-style-bench.mjs +285 -0
- package/scripts/output-style-smoke.mjs +13 -10
- package/scripts/patch-replay.mjs +90 -0
- package/scripts/provider-stream-stall-test.mjs +276 -0
- package/scripts/provider-toolcall-test.mjs +599 -1
- package/scripts/routing-corpus.mjs +281 -0
- package/scripts/session-bench.mjs +1526 -0
- package/scripts/session-diag.mjs +595 -0
- package/scripts/task-bench.mjs +207 -0
- package/scripts/tool-failures.mjs +6 -6
- package/scripts/tool-smoke.mjs +306 -66
- package/scripts/toolcall-args-test.mjs +81 -0
- package/src/agents/debugger/AGENT.md +4 -4
- package/src/agents/heavy-worker/AGENT.md +4 -2
- package/src/agents/reviewer/AGENT.md +4 -4
- package/src/agents/worker/AGENT.md +4 -2
- package/src/app.mjs +10 -6
- package/src/defaults/{hidden-roles.json → agents.json} +7 -7
- package/src/examples/schedules/SCHEDULE.example.md +32 -0
- package/src/examples/webhooks/WEBHOOK.example.md +40 -0
- package/src/headless-role.mjs +14 -14
- package/src/help.mjs +1 -0
- package/src/lib/rules-builder.cjs +32 -54
- package/src/mixdog-session-runtime.mjs +710 -318
- package/src/output-styles/default.md +12 -7
- package/src/output-styles/minimal.md +25 -0
- package/src/output-styles/oneline.md +21 -0
- package/src/output-styles/simple.md +10 -9
- package/src/repl.mjs +12 -2
- package/src/rules/agent/00-common.md +7 -5
- package/src/rules/agent/30-explorer.md +7 -8
- package/src/rules/lead/01-general.md +3 -1
- package/src/rules/lead/lead-tool.md +7 -0
- package/src/rules/shared/01-tool.md +17 -12
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
- package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
- package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
- package/src/runtime/agent/orchestrator/config.mjs +3 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
- package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
- package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
- package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
- package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +332 -57
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +59 -32
- package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +78 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +202 -98
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +183 -20
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
- package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -9
- package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
- package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
- package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
- package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
- package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
- package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
- package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
- package/src/runtime/channels/backends/discord.mjs +99 -9
- package/src/runtime/channels/backends/telegram.mjs +501 -0
- package/src/runtime/channels/index.mjs +441 -1224
- package/src/runtime/channels/lib/config.mjs +54 -2
- package/src/runtime/channels/lib/format.mjs +4 -2
- package/src/runtime/channels/lib/output-forwarder.mjs +80 -67
- package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
- package/src/runtime/channels/lib/scheduler.mjs +1 -1
- package/src/runtime/channels/lib/telegram-format.mjs +283 -0
- package/src/runtime/channels/lib/tool-format.mjs +1 -1
- package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
- package/src/runtime/channels/lib/webhook.mjs +59 -31
- package/src/runtime/channels/tool-defs.mjs +1 -1
- package/src/runtime/memory/index.mjs +184 -19
- package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
- package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
- package/src/runtime/memory/lib/memory.mjs +101 -4
- package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
- package/src/runtime/memory/lib/session-ingest.mjs +107 -0
- package/src/runtime/memory/lib/trace-store.mjs +69 -22
- package/src/runtime/memory/tool-defs.mjs +6 -3
- package/src/runtime/shared/channel-notification-routing.mjs +12 -0
- package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
- package/src/runtime/shared/config.mjs +9 -0
- package/src/runtime/shared/llm/http-agent.mjs +12 -5
- package/src/runtime/shared/schedules-store.mjs +21 -19
- package/src/runtime/shared/tool-surface.mjs +98 -13
- package/src/runtime/shared/transcript-writer.mjs +129 -0
- package/src/runtime/shared/update-checker.mjs +214 -0
- package/src/standalone/agent-tool.mjs +255 -109
- package/src/standalone/channel-admin.mjs +133 -40
- package/src/standalone/channel-worker.mjs +8 -291
- package/src/standalone/explore-tool.mjs +2 -2
- package/src/standalone/memory-runtime-proxy.mjs +3 -1
- package/src/standalone/provider-admin.mjs +11 -0
- package/src/standalone/usage-dashboard.mjs +1 -1
- package/src/tui/App.jsx +2096 -732
- package/src/tui/components/ConfirmBar.jsx +47 -0
- package/src/tui/components/ContextPanel.jsx +5 -3
- package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
- package/src/tui/components/Markdown.jsx +22 -98
- package/src/tui/components/Message.jsx +14 -35
- package/src/tui/components/Picker.jsx +87 -12
- package/src/tui/components/PromptInput.jsx +83 -7
- package/src/tui/components/QueuedCommands.jsx +1 -1
- package/src/tui/components/SlashCommandPalette.jsx +8 -5
- package/src/tui/components/Spinner.jsx +7 -7
- package/src/tui/components/StatusLine.jsx +40 -21
- package/src/tui/components/TextEntryPanel.jsx +51 -7
- package/src/tui/components/ToolExecution.jsx +170 -98
- package/src/tui/components/TurnDone.jsx +4 -4
- package/src/tui/components/UsagePanel.jsx +1 -1
- package/src/tui/components/tool-output-format.mjs +159 -21
- package/src/tui/components/tool-output-format.test.mjs +87 -0
- package/src/tui/display-width.mjs +69 -0
- package/src/tui/display-width.test.mjs +35 -0
- package/src/tui/dist/index.mjs +6965 -2391
- package/src/tui/engine.mjs +287 -126
- package/src/tui/index.jsx +117 -7
- package/src/tui/keyboard-protocol.mjs +42 -0
- package/src/tui/lib/voice-recorder.mjs +453 -0
- package/src/tui/markdown/format-token.mjs +129 -76
- package/src/tui/markdown/format-token.test.mjs +61 -19
- package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
- package/src/tui/markdown/render-ansi.test.mjs +1 -1
- package/src/tui/markdown/streaming-markdown.mjs +167 -0
- package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
- package/src/tui/markdown/table-layout.mjs +9 -9
- package/src/tui/paste-attachments.mjs +0 -11
- package/src/tui/prompt-history-store.mjs +129 -0
- package/src/tui/prompt-history-store.test.mjs +52 -0
- package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
- package/src/tui/theme.mjs +41 -657
- package/src/tui/themes/base.mjs +86 -0
- package/src/tui/themes/basic.mjs +85 -0
- package/src/tui/themes/catppuccin.mjs +72 -0
- package/src/tui/themes/dracula.mjs +70 -0
- package/src/tui/themes/everforest.mjs +71 -0
- package/src/tui/themes/gruvbox.mjs +71 -0
- package/src/tui/themes/index.mjs +71 -0
- package/src/tui/themes/indigo.mjs +78 -0
- package/src/tui/themes/kanagawa.mjs +80 -0
- package/src/tui/themes/light.mjs +81 -0
- package/src/tui/themes/nord.mjs +72 -0
- package/src/tui/themes/onedark.mjs +16 -0
- package/src/tui/themes/rosepine.mjs +70 -0
- package/src/tui/themes/teal.mjs +81 -0
- package/src/tui/themes/tokyonight.mjs +79 -0
- package/src/tui/themes/utils.mjs +106 -0
- package/src/tui/themes/warm.mjs +79 -0
- package/src/tui/transcript-tool-failures.mjs +13 -2
- package/src/ui/markdown.mjs +1 -1
- package/src/ui/model-display.mjs +2 -2
- package/src/ui/statusline.mjs +26 -27
- package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
- package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
- package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
- package/src/workflows/default/WORKFLOW.md +39 -12
- package/src/workflows/sequential/WORKFLOW.md +46 -0
- package/src/workflows/solo/WORKFLOW.md +7 -0
- package/vendor/ink/build/display-width.js +62 -0
- package/vendor/ink/build/ink.js +100 -12
- package/vendor/ink/build/measure-text.js +4 -1
- package/vendor/ink/build/output.js +115 -9
- package/vendor/ink/build/render-node-to-output.js +4 -1
- package/vendor/ink/build/render.js +4 -0
- package/src/output-styles/extreme-simple.md +0 -20
- package/src/rules/lead/04-workflow.md +0 -51
- package/src/workflows/default/workflow.json +0 -13
- package/src/workflows/solo/workflow.json +0 -7
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leaked tool-call recovery for the shared Anthropic SSE parser.
|
|
3
|
+
*
|
|
4
|
+
* Opus (via OAuth) and, less often, the API model sometimes emit a tool
|
|
5
|
+
* call as plain text tags inside `text_delta` (e.g. `<function_calls>`,
|
|
6
|
+
* `<invoke name="...">`, `<invoke ...>`, or a stray prefix like
|
|
7
|
+
* `course<invoke ...>`) instead of a native `tool_use` content block. Left
|
|
8
|
+
* alone those tags stream to the TUI as assistant prose and the tool never
|
|
9
|
+
* runs.
|
|
10
|
+
*
|
|
11
|
+
* `scanLeakedToolCalls` is the boundary-gated scanner the parser feeds its
|
|
12
|
+
* rolling text window. Unlike hermes-agent's strip-only port (which only
|
|
13
|
+
* DROPS the call), this recovers a real call: it suppresses the tags from
|
|
14
|
+
* the visible stream AND returns a synthesizable `{ name, arguments }` when
|
|
15
|
+
* the leaked payload parses to a KNOWN tool. Unknown / unparseable / prose
|
|
16
|
+
* `<function>` mentions are flushed as ordinary text so no user-visible
|
|
17
|
+
* content is ever lost.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { traceHash, stableTraceStringify } from './trace-utils.mjs';
|
|
21
|
+
|
|
22
|
+
// Opener strings (lowercased) used only for partial-tail hold detection —
|
|
23
|
+
// so a sentinel split across text_delta chunk boundaries is still caught.
|
|
24
|
+
const OPENERS = [
|
|
25
|
+
'<function_calls',
|
|
26
|
+
'<invoke',
|
|
27
|
+
'<function', // Gemma-style; name= is gated at match time
|
|
28
|
+
];
|
|
29
|
+
// OpenAI harmony / gpt-oss channel-syntax openers. Only consulted when a
|
|
30
|
+
// caller opts in via `{ harmony: true }` (OpenAI-family stream parsers) so the
|
|
31
|
+
// Anthropic path — and its regression tests — are byte-for-byte unaffected.
|
|
32
|
+
const HARMONY_OPENERS = [
|
|
33
|
+
'<|channel|>',
|
|
34
|
+
'<|start|>',
|
|
35
|
+
];
|
|
36
|
+
const LONGEST_OPENER = Math.max(
|
|
37
|
+
...OPENERS.map((o) => o.length),
|
|
38
|
+
...HARMONY_OPENERS.map((o) => o.length),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// First *definite* leaked-tool-call sentinel. The bare `<function` opener is
|
|
42
|
+
// boundary-gated to require a `name=` attribute so prose like
|
|
43
|
+
// "Use <function> in JavaScript" is never treated as a call.
|
|
44
|
+
// NOTE (Fix 3): a real leak ALWAYS carries the leading `<` bracket
|
|
45
|
+
// (`<invoke ...>`, `<invoke ...>`, `<function_calls>`); the model never
|
|
46
|
+
// emits a bare `antml:invoke` token without it. The old bare-`antml:` opener
|
|
47
|
+
// therefore only ever matched ordinary prose that mentions the literal string,
|
|
48
|
+
// and — because `matchCompleteBlockAt` has no bare form — it forced the scanner
|
|
49
|
+
// to HOLD from that token to end of stream. Removed: only bracketed forms are
|
|
50
|
+
// sentinels now, so prose containing "antml:invoke" mid-sentence streams
|
|
51
|
+
// promptly and is never recovered.
|
|
52
|
+
const SENTINEL_RE = /<(?:antml:)?function_calls\b|<(?:antml:)?invoke\b|<(?:antml:)?function\b[^>]*\bname\s*=/i;
|
|
53
|
+
|
|
54
|
+
// Harmony channel-syntax sentinel: the `<|channel|>` (optionally preceded by a
|
|
55
|
+
// `<|start|>` role token) that frames a gpt-oss tool call. Only whether it is a
|
|
56
|
+
// *tool* call is decided at block-match time (recipient `to=functions.NAME`).
|
|
57
|
+
const HARMONY_SENTINEL_RE = /<\|(?:channel|start)\|>/i;
|
|
58
|
+
// Terminator of a harmony message. A tool call ends with `<|call|>`; ordinary
|
|
59
|
+
// channel content ends with `<|end|>` / `<|return|>` — all three close a block
|
|
60
|
+
// so benign channel text flushes promptly instead of stalling to stream end.
|
|
61
|
+
const HARMONY_TERMINATOR_RE = /<\|call\|>|<\|end\|>|<\|return\|>/i;
|
|
62
|
+
const HARMONY_RECIPIENT_RE = /to=functions\.([A-Za-z0-9_.\-]+)/i;
|
|
63
|
+
const HARMONY_MESSAGE_RE = /<\|message\|>/i;
|
|
64
|
+
|
|
65
|
+
function firstSentinelIndex(s, harmony) {
|
|
66
|
+
const m = SENTINEL_RE.exec(s);
|
|
67
|
+
let idx = m ? m.index : -1;
|
|
68
|
+
if (harmony) {
|
|
69
|
+
const hm = HARMONY_SENTINEL_RE.exec(s);
|
|
70
|
+
if (hm && (idx === -1 || hm.index < idx)) idx = hm.index;
|
|
71
|
+
}
|
|
72
|
+
return idx;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isPrefixOfAnyOpener(tail, harmony) {
|
|
76
|
+
const t = tail.toLowerCase();
|
|
77
|
+
if (OPENERS.some((o) => o.startsWith(t))) return true;
|
|
78
|
+
return harmony ? HARMONY_OPENERS.some((o) => o.startsWith(t)) : false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- Markdown code-fence / inline-code tracking (Fix 1) --------------------
|
|
82
|
+
// A leaked tool-call tag written INSIDE a fenced code block (``` … ``` or
|
|
83
|
+
// ~~~ … ~~~) or an inline code span (`…`) is a documentation example, not a
|
|
84
|
+
// real call — it must stream as visible text, never be recovered/executed.
|
|
85
|
+
// Because the scanner runs on a rolling buffer, the fence state is tracked as
|
|
86
|
+
// a small struct threaded across chunks (a fence opened in one delta and
|
|
87
|
+
// closed in a later one persists). `advanceFenceState` folds a text slice into
|
|
88
|
+
// the state; `isInCode` reports whether a position sits inside code.
|
|
89
|
+
function initialFenceState() {
|
|
90
|
+
return { fenceChar: null, fenceLen: 0, inlineLen: 0, atLineStart: true };
|
|
91
|
+
}
|
|
92
|
+
function isInCode(s) {
|
|
93
|
+
return !!s && (s.fenceChar !== null || s.inlineLen > 0);
|
|
94
|
+
}
|
|
95
|
+
// Fold `text` into fence state and return the new state (input not mutated).
|
|
96
|
+
// Fenced blocks (``` / ~~~ at line start, length ≥ 3) persist across lines
|
|
97
|
+
// until a closing fence of the same char (length ≥ opener) at line start.
|
|
98
|
+
// Inline spans (backtick run not opening a fence) are line-local: reset at
|
|
99
|
+
// newline so a stray unmatched backtick in prose cannot poison later lines
|
|
100
|
+
// (which would wrongly suppress a genuine leaked call).
|
|
101
|
+
function advanceFenceState(state, text) {
|
|
102
|
+
let { fenceChar, fenceLen, inlineLen, atLineStart } = state || initialFenceState();
|
|
103
|
+
let i = 0;
|
|
104
|
+
while (i < text.length) {
|
|
105
|
+
const ch = text[i];
|
|
106
|
+
if (ch === '`' || ch === '~') {
|
|
107
|
+
let j = i;
|
|
108
|
+
while (j < text.length && text[j] === ch) j++;
|
|
109
|
+
const runLen = j - i;
|
|
110
|
+
if (fenceChar) {
|
|
111
|
+
if (ch === fenceChar && atLineStart && runLen >= fenceLen) {
|
|
112
|
+
fenceChar = null; fenceLen = 0;
|
|
113
|
+
}
|
|
114
|
+
} else if (inlineLen) {
|
|
115
|
+
if (ch === '`' && runLen === inlineLen) inlineLen = 0;
|
|
116
|
+
} else if (atLineStart && runLen >= 3) {
|
|
117
|
+
fenceChar = ch; fenceLen = runLen;
|
|
118
|
+
} else if (ch === '`') {
|
|
119
|
+
inlineLen = runLen;
|
|
120
|
+
}
|
|
121
|
+
atLineStart = false;
|
|
122
|
+
i = j;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (ch === '\n') {
|
|
126
|
+
atLineStart = true;
|
|
127
|
+
inlineLen = 0; // inline spans do not cross lines here
|
|
128
|
+
i++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (atLineStart && (ch === ' ' || ch === '\t')) { i++; continue; }
|
|
132
|
+
atLineStart = false;
|
|
133
|
+
i++;
|
|
134
|
+
}
|
|
135
|
+
return { fenceChar, fenceLen, inlineLen, atLineStart };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Index from which the tail of `s` could be the *start* of a sentinel that
|
|
139
|
+
// has not fully arrived yet. Everything before it is safe to flush now;
|
|
140
|
+
// from it onward must be held for the next chunk. Returns s.length when
|
|
141
|
+
// nothing needs holding (normal text streams promptly).
|
|
142
|
+
function partialTailIndex(s, harmony) {
|
|
143
|
+
const start = Math.max(0, s.length - LONGEST_OPENER);
|
|
144
|
+
for (let i = start; i < s.length; i++) {
|
|
145
|
+
if (isPrefixOfAnyOpener(s.slice(i), harmony)) return i;
|
|
146
|
+
}
|
|
147
|
+
return s.length;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Coerce a leaked `<parameter>` value. apply_patch-style raw bodies and plain
|
|
151
|
+
// strings pass through verbatim; JSON-looking scalars/objects are parsed so
|
|
152
|
+
// the synthesized arguments match a native tool_use input shape.
|
|
153
|
+
function coerceValue(raw) {
|
|
154
|
+
const trimmed = raw.trim();
|
|
155
|
+
if (trimmed === '') return raw;
|
|
156
|
+
const first = trimmed[0];
|
|
157
|
+
if ('{[0123456789tfn"-'.includes(first)) {
|
|
158
|
+
try { return JSON.parse(trimmed); } catch { /* fall through to raw */ }
|
|
159
|
+
}
|
|
160
|
+
return raw;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseParams(text) {
|
|
164
|
+
const out = {};
|
|
165
|
+
const re = /<(?:antml:)?parameter\b[^>]*\bname\s*=\s*["']?([^"'>\s]+)["']?[^>]*>([\s\S]*?)<\/(?:antml:)?parameter\s*>/gi;
|
|
166
|
+
let m;
|
|
167
|
+
while ((m = re.exec(text)) !== null) {
|
|
168
|
+
out[m[1]] = coerceValue(m[2]);
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseInvoke(text) {
|
|
174
|
+
const nameM = /<(?:antml:)?invoke\b[^>]*\bname\s*=\s*["']?([^"'>\s]+)["']?/i.exec(text);
|
|
175
|
+
if (!nameM) return null;
|
|
176
|
+
return { name: nameM[1], arguments: parseParams(text) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Match a COMPLETE harmony channel block starting at `idx` (which points at a
|
|
180
|
+
// `<|channel|>` or `<|start|>` token). Returns `{ end, call }` where `call` is
|
|
181
|
+
// `{ name, arguments }` when the block is a `to=functions.NAME` tool call, or
|
|
182
|
+
// null when it is ordinary channel content (flushed as visible text). Returns
|
|
183
|
+
// null (undecided → caller holds) until a terminator token has arrived.
|
|
184
|
+
function matchCompleteHarmonyBlockAt(s, idx) {
|
|
185
|
+
const rest = s.slice(idx);
|
|
186
|
+
if (!/^<\|(?:channel|start)\|>/i.test(rest)) return null;
|
|
187
|
+
const termM = HARMONY_TERMINATOR_RE.exec(rest);
|
|
188
|
+
if (!termM) return null;
|
|
189
|
+
const end = idx + termM.index + termM[0].length;
|
|
190
|
+
const block = rest.slice(0, termM.index);
|
|
191
|
+
const recM = HARMONY_RECIPIENT_RE.exec(block);
|
|
192
|
+
if (!recM) return { end, call: null };
|
|
193
|
+
const msgM = HARMONY_MESSAGE_RE.exec(block);
|
|
194
|
+
let args = {};
|
|
195
|
+
if (msgM) {
|
|
196
|
+
const raw = block.slice(msgM.index + msgM[0].length).trim();
|
|
197
|
+
if (raw) {
|
|
198
|
+
const coerced = coerceValue(raw);
|
|
199
|
+
if (coerced && typeof coerced === 'object' && !Array.isArray(coerced)) args = coerced;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { end, call: { name: recM[1], arguments: args } };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Try to match a COMPLETE leaked block starting at `idx`. Returns
|
|
206
|
+
// `{ end, call }` where `end` is the index in `s` just past the block and
|
|
207
|
+
// `call` is `{ name, arguments }` (or null when the block is well-formed XML
|
|
208
|
+
// but carries no parseable invoke). Returns null when the block has not
|
|
209
|
+
// finished arriving yet (caller holds and waits for more chunks).
|
|
210
|
+
function matchCompleteBlockAt(s, idx, harmony) {
|
|
211
|
+
const rest = s.slice(idx);
|
|
212
|
+
|
|
213
|
+
// Harmony channel syntax (opt-in). Routed first because its opener token
|
|
214
|
+
// (`<|...|>`) never collides with the XML `<function...>` families below.
|
|
215
|
+
if (harmony && /^<\|(?:channel|start)\|>/i.test(rest)) {
|
|
216
|
+
return matchCompleteHarmonyBlockAt(s, idx);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// <function_calls> ... </function_calls> wrapper
|
|
220
|
+
const wm = /^<(?:antml:)?function_calls\b[^>]*>/i.exec(rest);
|
|
221
|
+
if (wm) {
|
|
222
|
+
const cm = /<\/(?:antml:)?function_calls\s*>/i.exec(rest);
|
|
223
|
+
if (!cm) return null;
|
|
224
|
+
const end = idx + cm.index + cm[0].length;
|
|
225
|
+
const inner = rest.slice(wm[0].length, cm.index);
|
|
226
|
+
return { end, call: parseInvoke(inner) };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// <invoke ...> ... </invoke>
|
|
230
|
+
const im = /^<(?:antml:)?invoke\b[^>]*>/i.exec(rest);
|
|
231
|
+
if (im) {
|
|
232
|
+
const cm = /<\/(?:antml:)?invoke\s*>/i.exec(rest);
|
|
233
|
+
if (!cm) return null;
|
|
234
|
+
const blockText = rest.slice(0, cm.index + cm[0].length);
|
|
235
|
+
return { end: idx + blockText.length, call: parseInvoke(blockText) };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// <function name="..."> ... </function> (Gemma-style)
|
|
239
|
+
const fm = /^<(?:antml:)?function\b[^>]*\bname\s*=\s*["']?([^"'>\s]+)["']?[^>]*>/i.exec(rest);
|
|
240
|
+
if (fm) {
|
|
241
|
+
const cm = /<\/(?:antml:)?function\s*>/i.exec(rest);
|
|
242
|
+
if (!cm) return null;
|
|
243
|
+
const end = idx + cm.index + cm[0].length;
|
|
244
|
+
const inner = rest.slice(fm[0].length, cm.index);
|
|
245
|
+
return { end, call: fm[1] ? { name: fm[1], arguments: parseParams(inner) } : null };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Sentinel matched (e.g. bare `antml:invoke` with no `<`) but no complete
|
|
249
|
+
// block form — leave undecided so the caller holds (or flushes on final).
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Scan a rolling text window for leaked tool-call syntax.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} buffer Held-back text window.
|
|
257
|
+
* @param {object} opts
|
|
258
|
+
* @param {(name:string)=>boolean} opts.isKnownTool Validates the recovered
|
|
259
|
+
* tool name against the tools available to this request.
|
|
260
|
+
* @param {boolean} opts.final True on stream end: flush everything, never
|
|
261
|
+
* hold (legitimate text is never lost).
|
|
262
|
+
* @param {boolean} [opts.harmony] Opt-in (OpenAI-family callers only): also
|
|
263
|
+
* recognize gpt-oss/harmony `<|channel|>...to=functions.NAME...<|call|>`
|
|
264
|
+
* tool syntax. Off by default so the Anthropic path is unaffected.
|
|
265
|
+
* @param {object} [opts.fenceState] Running markdown fence/inline-code state
|
|
266
|
+
* from the previous chunk (Fix 1). A fence opened in an earlier delta and
|
|
267
|
+
* closed in a later one is respected because `fence` reflects the state
|
|
268
|
+
* as-of the start of `buffer`. Omit for single-shot use.
|
|
269
|
+
* @returns {{ emit:string, calls:Array<{name:string,arguments:object}>, rest:string, fenceState:object }}
|
|
270
|
+
* `emit` = text safe to forward now, `calls` = recovered known-tool
|
|
271
|
+
* calls to synthesize/dispatch, `rest` = text to keep buffered,
|
|
272
|
+
* `fenceState` = markdown state as-of the start of `rest` (thread it
|
|
273
|
+
* back in on the next call).
|
|
274
|
+
*/
|
|
275
|
+
export function scanLeakedToolCalls(buffer, { isKnownTool, final, harmony = false, fenceState = null }) {
|
|
276
|
+
let emit = '';
|
|
277
|
+
const calls = [];
|
|
278
|
+
const buf = buffer;
|
|
279
|
+
let pos = 0;
|
|
280
|
+
// Fix 1: fence state advances over EVERY consumed character in raw order,
|
|
281
|
+
// so `isInCode` at a sentinel reflects the running markdown context —
|
|
282
|
+
// including a fence opened in an earlier chunk (caller threads fenceState).
|
|
283
|
+
let fence = fenceState || initialFenceState();
|
|
284
|
+
// `asText` controls both whether the slice is emitted AND whether it
|
|
285
|
+
// advances markdown fence state. A recovered/SUPPRESSED tool-call block
|
|
286
|
+
// (asText=false) is hidden from the visible stream, so it must NOT feed the
|
|
287
|
+
// fence tracker either (Fix 2 follow-up / reviewer Medium): an unmatched
|
|
288
|
+
// backtick inside the hidden XML/harmony args would otherwise leave a fence
|
|
289
|
+
// "open" and wrongly suppress a subsequent REAL leaked call as in-code.
|
|
290
|
+
// Fence state only ever reflects text the user actually sees.
|
|
291
|
+
const consume = (to, asText) => {
|
|
292
|
+
if (asText) {
|
|
293
|
+
fence = advanceFenceState(fence, buf.slice(pos, to));
|
|
294
|
+
emit += buf.slice(pos, to);
|
|
295
|
+
}
|
|
296
|
+
pos = to;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
while (pos < buf.length) {
|
|
300
|
+
const rel = firstSentinelIndex(buf.slice(pos), harmony);
|
|
301
|
+
if (rel === -1) {
|
|
302
|
+
if (final) { consume(buf.length, true); }
|
|
303
|
+
else {
|
|
304
|
+
const hold = partialTailIndex(buf.slice(pos), harmony);
|
|
305
|
+
consume(pos + hold, true);
|
|
306
|
+
}
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
const idx = pos + rel;
|
|
310
|
+
// Plain text before the sentinel always streams as visible text.
|
|
311
|
+
consume(idx, true);
|
|
312
|
+
const inCode = isInCode(fence);
|
|
313
|
+
const block = matchCompleteBlockAt(buf, idx, harmony);
|
|
314
|
+
if (!block) {
|
|
315
|
+
// Opener present but block not fully arrived.
|
|
316
|
+
if (final) { consume(buf.length, true); break; }
|
|
317
|
+
if (inCode) {
|
|
318
|
+
// Inside a code fence / inline span: this can never become a
|
|
319
|
+
// real recovered call — emit the opener char and keep scanning
|
|
320
|
+
// instead of holding it (and the rest of the doc) to stream end.
|
|
321
|
+
consume(idx + 1, true);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
// Outside code: hold from the opener for the next chunk.
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
const { end, call } = block;
|
|
328
|
+
if (!inCode && call && call.name && isKnownTool(call.name)) {
|
|
329
|
+
// Recovered a real, known tool call OUTSIDE any code fence:
|
|
330
|
+
// suppress the tags from the visible stream and synthesize it.
|
|
331
|
+
consume(end, false);
|
|
332
|
+
calls.push({ name: call.name, arguments: call.arguments || {} });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
// In code, unknown tool, or no parseable invoke: keep the block as
|
|
336
|
+
// ordinary visible text and continue scanning past it.
|
|
337
|
+
consume(end, true);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return { emit, calls, rest: buf.slice(pos), fenceState: fence };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Stateful convenience wrapper around `scanLeakedToolCalls` for the streaming
|
|
345
|
+
* parsers. Holds the minimal rolling text window across chunk boundaries and
|
|
346
|
+
* exposes `push(delta)` / `flush()` that return the visible text safe to
|
|
347
|
+
* forward now plus any recovered known-tool calls. The Anthropic parser keeps
|
|
348
|
+
* its own inline wiring; this factory is shared by the OpenAI-family stream
|
|
349
|
+
* parsers so each only wires provider-specific call synthesis + dispatch.
|
|
350
|
+
*
|
|
351
|
+
* @param {object} opts
|
|
352
|
+
* @param {Set<string>|string[]} opts.knownToolNames Tools offered this request.
|
|
353
|
+
* @param {boolean} [opts.harmony] Opt-in gpt-oss/harmony channel detection.
|
|
354
|
+
* @returns {{ enabled:boolean, push:(delta:string, final?:boolean)=>{text:string,calls:Array<{name:string,arguments:object}>}, flush:()=>{text:string,calls:Array<{name:string,arguments:object}>} }}
|
|
355
|
+
*/
|
|
356
|
+
export function createLeakGuard({ knownToolNames, harmony = false } = {}) {
|
|
357
|
+
const known = knownToolNames instanceof Set
|
|
358
|
+
? knownToolNames
|
|
359
|
+
: new Set(Array.isArray(knownToolNames) ? knownToolNames : []);
|
|
360
|
+
const enabled = known.size > 0;
|
|
361
|
+
const isKnownTool = (name) => known.has(name);
|
|
362
|
+
let buffer = '';
|
|
363
|
+
// Running markdown fence/inline-code state threaded across chunks (Fix 1).
|
|
364
|
+
let fence = initialFenceState();
|
|
365
|
+
const run = (delta, final) => {
|
|
366
|
+
if (!enabled) return { text: delta || '', calls: [] };
|
|
367
|
+
buffer += delta || '';
|
|
368
|
+
if (!buffer && !final) return { text: '', calls: [] };
|
|
369
|
+
const { emit, calls, rest, fenceState } = scanLeakedToolCalls(buffer, { isKnownTool, final, harmony, fenceState: fence });
|
|
370
|
+
buffer = rest;
|
|
371
|
+
fence = fenceState;
|
|
372
|
+
return { text: emit, calls };
|
|
373
|
+
};
|
|
374
|
+
return {
|
|
375
|
+
enabled,
|
|
376
|
+
push: (delta, final = false) => run(delta, final),
|
|
377
|
+
flush: () => run('', true),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Shared name+args fingerprint dedupe for tool calls (Fix 2). The leak guard
|
|
383
|
+
* dispatches a synthesized call the instant it is recovered from text; the
|
|
384
|
+
* native tool_use / tool_calls path can later surface the SAME call. Without a
|
|
385
|
+
* cross-path guard both fire and a side-effecting tool double-executes. This
|
|
386
|
+
* factory holds a per-stream Set of dispatched fingerprints (name + stably
|
|
387
|
+
* stringified args) — call `shouldDispatch(name, args)` before EITHER a
|
|
388
|
+
* synthetic or a native dispatch: it returns false (skip) when an identical
|
|
389
|
+
* fingerprint already fired. Mirrors gemini.mjs's dedupe so all providers
|
|
390
|
+
* behave identically.
|
|
391
|
+
*/
|
|
392
|
+
export function toolCallFingerprint(name, args) {
|
|
393
|
+
let a = args;
|
|
394
|
+
if (a === null || typeof a !== 'object' || Array.isArray(a)) a = {};
|
|
395
|
+
return traceHash(stableTraceStringify({ name: name || '', args: a }));
|
|
396
|
+
}
|
|
397
|
+
export function createToolCallDedupe() {
|
|
398
|
+
const seen = new Set();
|
|
399
|
+
return {
|
|
400
|
+
// True the first time this (name,args) fingerprint is seen; false on
|
|
401
|
+
// any later identical call (synthetic-then-native or vice-versa).
|
|
402
|
+
shouldDispatch(name, args) {
|
|
403
|
+
const fp = toolCallFingerprint(name, args);
|
|
404
|
+
if (seen.has(fp)) return false;
|
|
405
|
+
seen.add(fp);
|
|
406
|
+
return true;
|
|
407
|
+
},
|
|
408
|
+
has(name, args) { return seen.has(toolCallFingerprint(name, args)); },
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Drop duplicate tool calls from a RETURNED `toolCalls` array by name+args
|
|
414
|
+
* fingerprint (Fix 2, array side). Dispatch-time dedupe (`createToolCallDedupe`)
|
|
415
|
+
* only suppresses the second `onToolCall`; providers that also RETURN a
|
|
416
|
+
* `toolCalls` array (which the agent loop executes) can still carry a
|
|
417
|
+
* synthetic-leaked + identical-native duplicate. Run the final array through
|
|
418
|
+
* this so the loop never executes a side-effecting tool twice. Order is
|
|
419
|
+
* preserved; the FIRST occurrence of each fingerprint wins.
|
|
420
|
+
*
|
|
421
|
+
* @param {Array<{name?:string,arguments?:object}>} calls
|
|
422
|
+
* @returns {Array} the array with later duplicate-fingerprint calls removed.
|
|
423
|
+
*/
|
|
424
|
+
export function dedupeToolCallList(calls) {
|
|
425
|
+
if (!Array.isArray(calls) || calls.length < 2) return calls;
|
|
426
|
+
const seen = new Set();
|
|
427
|
+
const out = [];
|
|
428
|
+
for (const call of calls) {
|
|
429
|
+
if (!call || typeof call !== 'object') { out.push(call); continue; }
|
|
430
|
+
const fp = toolCallFingerprint(call.name, call.arguments);
|
|
431
|
+
if (seen.has(fp)) continue;
|
|
432
|
+
seen.add(fp);
|
|
433
|
+
out.push(call);
|
|
434
|
+
}
|
|
435
|
+
return out.length === calls.length ? calls : out;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Exposed for focused unit tests.
|
|
439
|
+
export const _internals = {
|
|
440
|
+
firstSentinelIndex,
|
|
441
|
+
partialTailIndex,
|
|
442
|
+
matchCompleteBlockAt,
|
|
443
|
+
parseInvoke,
|
|
444
|
+
};
|