@miller-tech/uap 1.100.0 → 1.101.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/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +1 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/deliver.d.ts +5 -0
- package/dist/cli/deliver.d.ts.map +1 -1
- package/dist/cli/deliver.js +83 -1
- package/dist/cli/deliver.js.map +1 -1
- package/dist/delivery/contract-extractor.d.ts +31 -0
- package/dist/delivery/contract-extractor.d.ts.map +1 -0
- package/dist/delivery/contract-extractor.js +100 -0
- package/dist/delivery/contract-extractor.js.map +1 -0
- package/dist/delivery/index.d.ts +2 -0
- package/dist/delivery/index.d.ts.map +1 -1
- package/dist/delivery/index.js +2 -0
- package/dist/delivery/index.js.map +1 -1
- package/dist/delivery/task-orchestrator.d.ts +125 -0
- package/dist/delivery/task-orchestrator.d.ts.map +1 -0
- package/dist/delivery/task-orchestrator.js +185 -0
- package/dist/delivery/task-orchestrator.js.map +1 -0
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +101 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blackboard Task Orchestrator (P1) + Minimal Context Assembler (P2)
|
|
3
|
+
*
|
|
4
|
+
* The mechanism that lets a small-context model multi-step through a large
|
|
5
|
+
* build: instead of one convergence loop holding the WHOLE design in context,
|
|
6
|
+
* the orchestrator executes a task DAG where each leaf task runs in a FRESH,
|
|
7
|
+
* MINIMAL context assembled from externalized state — its own goal, its
|
|
8
|
+
* acceptance criteria, and ONLY the recorded outputs of its direct
|
|
9
|
+
* dependencies (not the full mission, not every prior summary).
|
|
10
|
+
*
|
|
11
|
+
* State lives on a "blackboard" (completed task outcomes), not in the prompt.
|
|
12
|
+
* A task that finishes writes a compact summary (P1) — and later its verified
|
|
13
|
+
* interface contract (P4) — back to the blackboard, so a dependent task loads
|
|
14
|
+
* a few hundred tokens of "what already exists" rather than re-reading source.
|
|
15
|
+
*
|
|
16
|
+
* This module is executor-agnostic: it takes a `runTask` callback that turns
|
|
17
|
+
* an assembled prompt into a pass/fail outcome (production wires the
|
|
18
|
+
* ConvergenceLoop; tests inject a deterministic stub). That keeps the graph
|
|
19
|
+
* logic unit-testable without a model.
|
|
20
|
+
*/
|
|
21
|
+
import { topoOrder } from './decompose.js';
|
|
22
|
+
const DEFAULT_DEP_SUMMARY_CHARS = 240;
|
|
23
|
+
/** A short mission line — orientation, not the full spec. */
|
|
24
|
+
const MISSION_SNIPPET_CHARS = 300;
|
|
25
|
+
const DEFAULT_CONTEXT_BUDGET_CHARS = 6000;
|
|
26
|
+
const DEFAULT_MAX_TASKS = 40;
|
|
27
|
+
/**
|
|
28
|
+
* P6 — enforce the context budget: if the assembled prompt exceeds `budget`,
|
|
29
|
+
* drop whole dependency lines from the END (furthest/least-recent deps) until
|
|
30
|
+
* it fits, appending a note about what was elided. Pure + unit-tested; the
|
|
31
|
+
* governor turns "minimal context" into a hard invariant.
|
|
32
|
+
*/
|
|
33
|
+
export function governContext(prompt, depLineCount, budget) {
|
|
34
|
+
if (prompt.length <= budget)
|
|
35
|
+
return { prompt, droppedDeps: 0 };
|
|
36
|
+
const lines = prompt.split('\n');
|
|
37
|
+
// dependency lines are the "- <id>: ..." entries under ALREADY BUILT.
|
|
38
|
+
let dropped = 0;
|
|
39
|
+
for (let i = lines.length - 1; i >= 0 && lines.join('\n').length > budget && dropped < depLineCount; i--) {
|
|
40
|
+
if (/^- [^:]+: /.test(lines[i])) {
|
|
41
|
+
lines.splice(i, 1);
|
|
42
|
+
dropped++;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
let out = lines.join('\n');
|
|
46
|
+
if (dropped > 0)
|
|
47
|
+
out += `\n(context governor: elided ${dropped} lower-priority dependency summary(ies) to fit the ${budget}-char budget)`;
|
|
48
|
+
// Last resort: hard truncate if still over (e.g. a single huge goal).
|
|
49
|
+
if (out.length > budget)
|
|
50
|
+
out = out.slice(0, budget) + '\n…(truncated to context budget)…';
|
|
51
|
+
return { prompt: out, droppedDeps: dropped };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* P2 — assemble one task's MINIMAL context: a short mission line, the task's
|
|
55
|
+
* own goal + criteria + declared files, and ONLY the summaries/contracts of
|
|
56
|
+
* its DIRECT dependencies (pulled from the blackboard). Pure and unit-tested;
|
|
57
|
+
* this is the function that keeps a small model's window optimal.
|
|
58
|
+
*/
|
|
59
|
+
export function assembleTaskContext(task, mission, blackboard, maxDepSummaryChars = DEFAULT_DEP_SUMMARY_CHARS, opts = {}) {
|
|
60
|
+
const sections = [];
|
|
61
|
+
// Orientation only — a snippet, never the whole mission.
|
|
62
|
+
sections.push(`OBJECTIVE (context, one line): ${mission.slice(0, MISSION_SNIPPET_CHARS)}`);
|
|
63
|
+
sections.push('');
|
|
64
|
+
sections.push(`YOUR TASK — ${task.title}:`);
|
|
65
|
+
sections.push(task.goal);
|
|
66
|
+
if (task.files && task.files.length > 0) {
|
|
67
|
+
sections.push('');
|
|
68
|
+
sections.push(`Files for THIS task: ${task.files.join(', ')} (edit only these).`);
|
|
69
|
+
}
|
|
70
|
+
if (task.criteria && task.criteria.length > 0) {
|
|
71
|
+
sections.push('');
|
|
72
|
+
sections.push('This task is done when:');
|
|
73
|
+
task.criteria.forEach((c, i) => sections.push(` ${i + 1}. ${c}`));
|
|
74
|
+
}
|
|
75
|
+
// P3 — relevant persisted design decisions (objective/architecture): a
|
|
76
|
+
// small retrieval, not the full spec dump.
|
|
77
|
+
if (opts.designLines && opts.designLines.length > 0) {
|
|
78
|
+
sections.push('');
|
|
79
|
+
sections.push('DESIGN CONTEXT (established decisions — honor them):');
|
|
80
|
+
opts.designLines.slice(0, 6).forEach((d) => sections.push(`- ${d}`));
|
|
81
|
+
}
|
|
82
|
+
// ONLY direct dependencies' outputs — the crux of minimal context. A task
|
|
83
|
+
// that depends on 'store' loads store's contract/summary, not store's source
|
|
84
|
+
// and not the other 12 tasks. Dependents read the interface, not the tree.
|
|
85
|
+
const includedDeps = [];
|
|
86
|
+
const deps = (task.deps ?? []).filter((d) => blackboard.has(d));
|
|
87
|
+
if (deps.length > 0) {
|
|
88
|
+
sections.push('');
|
|
89
|
+
sections.push('ALREADY BUILT — build ON these, do not reimplement them:');
|
|
90
|
+
for (const depId of deps) {
|
|
91
|
+
const out = blackboard.get(depId);
|
|
92
|
+
includedDeps.push(depId);
|
|
93
|
+
const body = (out.contract ?? out.summary).slice(0, maxDepSummaryChars);
|
|
94
|
+
sections.push(`- ${depId}: ${body}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const raw = sections.join('\n');
|
|
98
|
+
const governed = governContext(raw, includedDeps.length, opts.budgetChars ?? DEFAULT_CONTEXT_BUDGET_CHARS);
|
|
99
|
+
return { taskId: task.id, prompt: governed.prompt, includedDeps };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* P1 — execute the task DAG on a blackboard. Topologically ordered; a task
|
|
103
|
+
* runs only after every dependency has SUCCEEDED. A failed task blocks its
|
|
104
|
+
* dependents (their deps aren't all green) and they are reported as skipped/
|
|
105
|
+
* failed rather than run against incomplete state. Fail-soft on publish.
|
|
106
|
+
*/
|
|
107
|
+
export async function orchestrate(config) {
|
|
108
|
+
const maxTasks = config.maxTasks ?? DEFAULT_MAX_TASKS;
|
|
109
|
+
const budget = config.contextBudgetChars;
|
|
110
|
+
// Mutable queue so P5 re-planning can append discovered subtasks; re-sorted
|
|
111
|
+
// topologically each time the set grows.
|
|
112
|
+
let queue = topoOrder(config.tasks);
|
|
113
|
+
const known = new Set(queue.map((t) => t.id));
|
|
114
|
+
const blackboard = new Map();
|
|
115
|
+
const done = new Set();
|
|
116
|
+
const failed = new Set();
|
|
117
|
+
const outcomes = [];
|
|
118
|
+
let turns = 0;
|
|
119
|
+
for (let qi = 0; qi < queue.length; qi++) {
|
|
120
|
+
const task = queue[qi];
|
|
121
|
+
const deps = task.deps ?? [];
|
|
122
|
+
// Skip a task whose dependency failed — never build against a broken base.
|
|
123
|
+
const blockedBy = deps.filter((d) => failed.has(d) || !done.has(d));
|
|
124
|
+
if (blockedBy.length > 0) {
|
|
125
|
+
const outcome = {
|
|
126
|
+
taskId: task.id,
|
|
127
|
+
success: false,
|
|
128
|
+
summary: `skipped — unmet dependency: ${blockedBy.join(', ')}`,
|
|
129
|
+
turns: 0,
|
|
130
|
+
};
|
|
131
|
+
failed.add(task.id);
|
|
132
|
+
outcomes.push(outcome);
|
|
133
|
+
config.onTask?.(task, outcome);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
let designLines = [];
|
|
137
|
+
if (config.retrieveDesign) {
|
|
138
|
+
try {
|
|
139
|
+
designLines = await config.retrieveDesign(task);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
designLines = [];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const ctx = assembleTaskContext(task, config.mission, blackboard, config.maxDepSummaryChars, {
|
|
146
|
+
designLines,
|
|
147
|
+
budgetChars: budget,
|
|
148
|
+
});
|
|
149
|
+
const outcome = await config.runTask(ctx, task);
|
|
150
|
+
turns += outcome.turns;
|
|
151
|
+
outcomes.push(outcome);
|
|
152
|
+
if (outcome.success) {
|
|
153
|
+
done.add(task.id);
|
|
154
|
+
blackboard.set(task.id, outcome);
|
|
155
|
+
try {
|
|
156
|
+
await config.publish?.(outcome, task);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// publishing to durable memory is best-effort
|
|
160
|
+
}
|
|
161
|
+
// P5 — adaptive re-planning: fold discovered subtasks into the queue.
|
|
162
|
+
const fresh = (outcome.newTasks ?? []).filter((t) => !known.has(t.id));
|
|
163
|
+
if (fresh.length > 0 && queue.length + fresh.length <= maxTasks) {
|
|
164
|
+
for (const t of fresh)
|
|
165
|
+
known.add(t.id);
|
|
166
|
+
// Re-topo-sort the not-yet-run remainder plus the new tasks so deps hold.
|
|
167
|
+
const remainder = queue.slice(qi + 1).concat(fresh);
|
|
168
|
+
const resorted = topoOrder(remainder);
|
|
169
|
+
queue = queue.slice(0, qi + 1).concat(resorted);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
failed.add(task.id);
|
|
174
|
+
}
|
|
175
|
+
config.onTask?.(task, outcome);
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
success: failed.size === 0,
|
|
179
|
+
completed: [...done],
|
|
180
|
+
failed: [...failed],
|
|
181
|
+
turns,
|
|
182
|
+
outcomes,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
//# sourceMappingURL=task-orchestrator.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"task-orchestrator.js","sourceRoot":"","sources":["../../src/delivery/task-orchestrator.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
@@ -205,6 +205,32 @@ PROXY_FORCED_HARD_RELEASE = int(os.environ.get("PROXY_FORCED_HARD_RELEASE", "30"
|
|
|
205
205
|
PROXY_CONTEXT_RELEASE_THRESHOLD = float(
|
|
206
206
|
os.environ.get("PROXY_CONTEXT_RELEASE_THRESHOLD", "0.90")
|
|
207
207
|
)
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# STUCK-BREAK guardrail: a small model can recognize it is looping ("I've been
|
|
210
|
+
# stuck in a loop, let me break out") yet keep repeating the SAME failing tool
|
|
211
|
+
# call -- meta-cognition without an exit. Two signals, both observed live on a
|
|
212
|
+
# qwen3.6 session that looped ~18min fetching a rate-limited GitHub API:
|
|
213
|
+
# (a) repeated self-reported "stuck" assistant text, and
|
|
214
|
+
# (b) repeated tool calls hitting a known rate-limited API host.
|
|
215
|
+
# When either streak crosses its threshold the proxy forces a TERMINAL turn:
|
|
216
|
+
# tool_choice back to auto + a firm directive to stop retrying and either
|
|
217
|
+
# proceed without the unreachable resource or ask the operator. Default on;
|
|
218
|
+
# PROXY_STUCK_BREAK=off to disable.
|
|
219
|
+
PROXY_STUCK_BREAK = os.environ.get("PROXY_STUCK_BREAK", "on").lower() not in {
|
|
220
|
+
"0", "false", "off", "no",
|
|
221
|
+
}
|
|
222
|
+
# Self-reported-stuck phrases (lowercased match). Deliberately narrow.
|
|
223
|
+
_STUCK_PHRASE_RE = re.compile(
|
|
224
|
+
r"stuck in a loop|been stuck|break out of (?:this|the) loop|going in circles|"
|
|
225
|
+
r"repeating myself|same (?:thing|error) (?:again|repeatedly)",
|
|
226
|
+
re.IGNORECASE,
|
|
227
|
+
)
|
|
228
|
+
# Tool args reaching into a rate-limited REST API host (the wrong channel; the
|
|
229
|
+
# hint steers to the browser tool / git clone, which are not rate-limited).
|
|
230
|
+
_RATE_LIMITED_API_RE = re.compile(r"api\.github\.com", re.IGNORECASE)
|
|
231
|
+
PROXY_STUCK_TEXT_THRESHOLD = int(os.environ.get("PROXY_STUCK_TEXT_THRESHOLD", "2"))
|
|
232
|
+
PROXY_STUCK_API_THRESHOLD = int(os.environ.get("PROXY_STUCK_API_THRESHOLD", "3"))
|
|
233
|
+
|
|
208
234
|
PROXY_TOOL_STATE_MACHINE = os.environ.get(
|
|
209
235
|
"PROXY_TOOL_STATE_MACHINE", "on"
|
|
210
236
|
).lower() not in {
|
|
@@ -1128,6 +1154,9 @@ class SessionMonitor:
|
|
|
1128
1154
|
recon_hard_fires: int = 0 # Fix E: monotonic count of recon hard-tier firings
|
|
1129
1155
|
catastrophic_ctx_streak: int = 0 # Fix F: consecutive turns raw ctx >= finalize ratio
|
|
1130
1156
|
unexpected_end_turn_count: int = 0 # end_turn without tool_use in active loop
|
|
1157
|
+
self_stuck_streak: int = 0 # consecutive assistant texts self-reporting a loop
|
|
1158
|
+
rate_limited_api_streak: int = 0 # consecutive tool calls hitting a rate-limited API host
|
|
1159
|
+
stuck_break_fires: int = 0 # monotonic count of forced stuck-breaks
|
|
1131
1160
|
tool_starvation_streak: int = 0 # Consecutive forced turns with no tool_calls produced
|
|
1132
1161
|
malformed_tool_streak: int = 0 # consecutive malformed pseudo tool payloads
|
|
1133
1162
|
invalid_tool_call_streak: int = 0 # consecutive invalid tool arg payloads
|
|
@@ -1363,6 +1392,40 @@ class SessionMonitor:
|
|
|
1363
1392
|
by_tool = self.tool_target_history.setdefault(name, {})
|
|
1364
1393
|
by_tool[target] = by_tool.get(target, 0) + 1
|
|
1365
1394
|
|
|
1395
|
+
def note_assistant_text(self, text: str) -> None:
|
|
1396
|
+
"""Track the model self-reporting that it is stuck (STUCK-BREAK signal
|
|
1397
|
+
(a)). A matching turn increments the streak; a non-matching turn resets
|
|
1398
|
+
it, so only SUSTAINED self-reported looping trips the break."""
|
|
1399
|
+
if not PROXY_STUCK_BREAK or not text:
|
|
1400
|
+
self.self_stuck_streak = 0
|
|
1401
|
+
return
|
|
1402
|
+
if _STUCK_PHRASE_RE.search(text):
|
|
1403
|
+
self.self_stuck_streak += 1
|
|
1404
|
+
else:
|
|
1405
|
+
self.self_stuck_streak = 0
|
|
1406
|
+
|
|
1407
|
+
def note_tool_arg_hosts(self, arg_blobs: list) -> None:
|
|
1408
|
+
"""Track repeated tool calls into a rate-limited API host (STUCK-BREAK
|
|
1409
|
+
signal (b)). Reset when a turn uses none, so only a sustained wrong-
|
|
1410
|
+
channel loop trips the hint."""
|
|
1411
|
+
if not PROXY_STUCK_BREAK:
|
|
1412
|
+
return
|
|
1413
|
+
blob = " ".join(a for a in arg_blobs if isinstance(a, str))
|
|
1414
|
+
if _RATE_LIMITED_API_RE.search(blob):
|
|
1415
|
+
self.rate_limited_api_streak += 1
|
|
1416
|
+
else:
|
|
1417
|
+
self.rate_limited_api_streak = 0
|
|
1418
|
+
|
|
1419
|
+
def should_force_stuck_break(self) -> tuple[bool, str]:
|
|
1420
|
+
"""True + reason when a terminal break should be forced this turn."""
|
|
1421
|
+
if not PROXY_STUCK_BREAK:
|
|
1422
|
+
return False, ""
|
|
1423
|
+
if self.self_stuck_streak >= PROXY_STUCK_TEXT_THRESHOLD:
|
|
1424
|
+
return True, f"self-reported stuck x{self.self_stuck_streak}"
|
|
1425
|
+
if self.rate_limited_api_streak >= PROXY_STUCK_API_THRESHOLD:
|
|
1426
|
+
return True, f"rate-limited-API retries x{self.rate_limited_api_streak}"
|
|
1427
|
+
return False, ""
|
|
1428
|
+
|
|
1366
1429
|
def has_duplicate_read_target(self, threshold: int = 2) -> tuple[bool, str]:
|
|
1367
1430
|
"""Check if any read-only tool has re-read the same target >= threshold times.
|
|
1368
1431
|
|
|
@@ -4067,6 +4130,36 @@ def _writes_are_gated(openai_body: dict) -> bool:
|
|
|
4067
4130
|
return False
|
|
4068
4131
|
|
|
4069
4132
|
|
|
4133
|
+
def _maybe_inject_stuck_break(openai_body: dict, monitor: "SessionMonitor") -> None:
|
|
4134
|
+
"""Force a terminal turn when the model is looping self-awarely or hammering
|
|
4135
|
+
a rate-limited API. Unlike the cycle-breaker (which narrows tools), this
|
|
4136
|
+
STOPS tool coercion and tells the model to synthesize / ask / route around
|
|
4137
|
+
the unreachable resource -- converting the model's own "I'm stuck" into an
|
|
4138
|
+
actual exit. Fires at most escalating; monotonic counter for telemetry."""
|
|
4139
|
+
should, reason = monitor.should_force_stuck_break()
|
|
4140
|
+
if not should:
|
|
4141
|
+
return
|
|
4142
|
+
monitor.stuck_break_fires += 1
|
|
4143
|
+
# Release the tool-choice coercion so a plain text turn is allowed.
|
|
4144
|
+
if openai_body.get("tool_choice") == "required":
|
|
4145
|
+
openai_body["tool_choice"] = "auto"
|
|
4146
|
+
directive = (
|
|
4147
|
+
"\n\nSTOP — you are repeating a failing action (" + reason + "). Do NOT "
|
|
4148
|
+
"retry the same tool or fetch again. If a resource is unreachable (e.g. a "
|
|
4149
|
+
"rate-limited GitHub REST API), switch channel: use the browser tool or "
|
|
4150
|
+
"`git clone` (git protocol), NOT api.github.com. If it is still "
|
|
4151
|
+
"unavailable, proceed WITHOUT it using what you already have, or ask the "
|
|
4152
|
+
"operator the single blocking question in one sentence. Take a DIFFERENT "
|
|
4153
|
+
"action now."
|
|
4154
|
+
)
|
|
4155
|
+
msgs = openai_body.get("messages") or []
|
|
4156
|
+
if msgs and msgs[0].get("role") == "system":
|
|
4157
|
+
msgs[0]["content"] = (msgs[0].get("content") or "") + directive
|
|
4158
|
+
else:
|
|
4159
|
+
msgs.insert(0, {"role": "system", "content": directive.strip()})
|
|
4160
|
+
logger.warning("STUCK-BREAK: forced terminal turn (%s, fires=%d)", reason, monitor.stuck_break_fires)
|
|
4161
|
+
|
|
4162
|
+
|
|
4070
4163
|
def _maybe_inject_recon_convergence(
|
|
4071
4164
|
openai_body: dict,
|
|
4072
4165
|
monitor: "SessionMonitor",
|
|
@@ -4846,6 +4939,8 @@ def build_openai_request(
|
|
|
4846
4939
|
# pre-narrowing toolset so it can restore a dropped write tool.
|
|
4847
4940
|
_maybe_inject_recon_convergence(openai_body, monitor, full_openai_tools)
|
|
4848
4941
|
|
|
4942
|
+
_maybe_inject_stuck_break(openai_body, monitor)
|
|
4943
|
+
|
|
4849
4944
|
_apply_thinking_grammar(openai_body)
|
|
4850
4945
|
|
|
4851
4946
|
_apply_json_response_grammar(openai_body, anthropic_body)
|
|
@@ -8612,6 +8707,12 @@ async def stream_anthropic_response(
|
|
|
8612
8707
|
tc_names,
|
|
8613
8708
|
[a[:200] for a in tc_args],
|
|
8614
8709
|
)
|
|
8710
|
+
# STUCK-BREAK signals: feed the assistant text + tool args to the monitor.
|
|
8711
|
+
try:
|
|
8712
|
+
monitor.note_assistant_text(accumulated_text)
|
|
8713
|
+
monitor.note_tool_arg_hosts(list(tc_args))
|
|
8714
|
+
except Exception:
|
|
8715
|
+
pass
|
|
8615
8716
|
|
|
8616
8717
|
# -------------------------------------------------------------------
|
|
8617
8718
|
# Post-stream: recover <tool_call> XML from accumulated text
|