bare-agent 0.27.0 → 0.28.0
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/README.md +1 -1
- package/bareagent.context.md +10 -2
- package/package.json +1 -1
- package/src/loop.d.ts +11 -2
- package/src/loop.js +70 -27
- package/src/provider-stop-reason.d.ts +8 -0
- package/src/provider-stop-reason.js +54 -2
- package/src/recurse-retrieval.d.ts +1 -1
- package/src/recurse-retrieval.js +2 -2
- package/src/recurse.js +1 -1
package/README.md
CHANGED
|
@@ -123,7 +123,7 @@ console.log(result.count, result.matchedIds); // a code-derived count + the id
|
|
|
123
123
|
|
|
124
124
|
**Providers:** OpenAI-compatible (OpenAI, OpenRouter, Groq, vLLM, LM Studio), Anthropic, Gemini (native), Ollama, CLIPipe, Fallback — or bring your own (one `generate` method). All return the same shape; swap freely. Usage including prompt-cache tiers is normalized, so `result.metrics` reports honest cumulative tokens + cost — and `null`, never a silent `0`, for a model it couldn't price. A model that rejects a non-default `temperature` (e.g. `claude-sonnet-5`, OpenAI o1/gpt-5-class return a `400`) is handled gracefully — the provider drops the param and retries once rather than failing the call, surfacing `temperatureDropped` so a caller can report the effective value.
|
|
125
125
|
|
|
126
|
-
**The run tells you the truth about what happened on the wire.** Every provider reports **why** generation ended (`stopReason`, normalized across all of them), so a round the API **cut off at the token cap** can no longer masquerade as a finished answer: it returns `error: 'truncated:max_tokens'` with the partial text preserved, instead of a silent `error: null` (BA-6). Its tool calls are **refused, never executed** — a *complete* tool call always arrives tagged `tool_use`, so one riding a truncated round was cut off mid-generation with arguments missing, which is exactly how a truncated `shell_write` can zero a file. `error` is the sole success signal; a bound firing preserves the model's work rather than discarding it (BA-5).
|
|
126
|
+
**The run tells you the truth about what happened on the wire.** Every provider reports **why** generation ended (`stopReason`, normalized across all of them and surfaced on **every** `Loop.run()` return), so a round the API **cut off at the token cap** can no longer masquerade as a finished answer: it returns `error: 'truncated:max_tokens'` with the partial text preserved, instead of a silent `error: null` (BA-6). Its tool calls are **refused, never executed** — a *complete* tool call always arrives tagged `tool_use`, so one riding a truncated round was cut off mid-generation with arguments missing, which is exactly how a truncated `shell_write` can zero a file. The same honesty now covers **every** non-clean terminal signal (BA-13): a safety `refusal` returns `error: 'refusal'` and a blown context window returns `error: 'context_exceeded'` (both were previously laundered into an empty success), while a resumable `pause_turn` makes the loop resume rather than terminate. `error` is the sole success signal; a bound firing preserves the model's work rather than discarding it (BA-5).
|
|
127
127
|
|
|
128
128
|
**Thinking blocks survive the round-trip.** `claude-sonnet-5` and Opus 4.7+ run adaptive thinking **by default** — they return `thinking` blocks whether or not you ask — and Anthropic's contract is that those blocks come back **unchanged, signature included**, when a tool-use conversation continues. bare-agent used to drop every one, silently (the API returns 200 either way). They now ride the transcript on `Message.providerBlocks` and are replayed verbatim, with no flag to set. `new Anthropic({ thinking })` is a separate opt-in for pinning the mode or reaching `display`/`effort` — it does *not* enable thinking, which is already on. **This is a protocol/data-loss fix, not a capability one: a head-to-head with thinking fully preserved produced no better outcomes, and we say so rather than sell it (BA-7).**
|
|
129
129
|
|
package/bareagent.context.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# bareagent — Integration Guide
|
|
2
2
|
|
|
3
3
|
> For AI assistants and developers wiring bareagent into a project.
|
|
4
|
-
> v0.
|
|
4
|
+
> v0.28.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
|
|
5
5
|
>
|
|
6
6
|
> Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)
|
|
7
7
|
|
|
@@ -388,7 +388,15 @@ Halt-severity decisions exit the loop cleanly via a typed `HaltError` — full m
|
|
|
388
388
|
|
|
389
389
|
**A TRUNCATED round is reported, never laundered into a finish (BA-6, v0.27+).** Every provider now surfaces `GenerateResult.stopReason` — normalized across all five to one neutral vocabulary (`end_turn`, `max_tokens`, `tool_use`, `stop_sequence`, `refusal`, `pause_turn`, `context_exceeded`; `null` when the provider doesn't report one, e.g. CLIPipe). Before this, **no** provider read its finish-reason field, so a round the API **cut off at the output cap** hit the Loop's *"no tool calls ⇒ final answer"* rule and came back as a **clean finish with `error: null`** — a truncation indistinguishable from a model that chose to stop. It now returns **`result.error === 'truncated:max_tokens'`** with the partial text preserved (BA-5). Check `result.error === null` for success, as always. Note the **default `maxTokens` is 4096** and is deliberately unchanged: raising it silently lifts every adopter's ceiling and bill, which is the same class of sin as the silent truncation. If you run a reasoning model on hard tasks, **raise it yourself** — `loop.run(msgs, tools, { maxTokens: 16000 })` (it's a `run()` option, forwarded to the provider), and now you'll get a loud error instead of a quiet empty answer when you don't.
|
|
390
390
|
|
|
391
|
-
**A truncated round's tool calls are REFUSED, and this is load-bearing.** A **complete** tool call always arrives tagged `tool_use`, **never** `max_tokens` (measured on the real API). So a tool call riding a truncated round was **cut off mid-generation with arguments missing** — which is exactly how a `shell_write` whose `content` never arrived emptied a 1789-line file (BA-4). The Loop therefore never executes the tool calls of a `max_tokens` round: it returns `truncated:max_tokens` instead. Refusing discards nothing legitimate, and it closes that data-loss path for **every** tool you grant, not just `shell_write`.
|
|
391
|
+
**A truncated round's tool calls are REFUSED, and this is load-bearing.** A **complete** tool call always arrives tagged `tool_use`, **never** `max_tokens` (measured on the real API). So a tool call riding a truncated round was **cut off mid-generation with arguments missing** — which is exactly how a `shell_write` whose `content` never arrived emptied a 1789-line file (BA-4). The Loop therefore never executes the tool calls of a `max_tokens` round: it returns `truncated:max_tokens` instead. Refusing discards nothing legitimate, and it closes that data-loss path for **every** tool you grant, not just `shell_write`.
|
|
392
|
+
|
|
393
|
+
**Every non-clean terminal stop reason is error-tagged, and `stopReason` rides every return (BA-13, v0.28+).** BA-6 fixed exactly one stop reason (`max_tokens`); the others still laundered a non-finish into a clean `error: null`. They no longer do:
|
|
394
|
+
- `refusal` → **`result.error === 'refusal'`** (partial text preserved). A safety refusal is **not** an empty success. `RECITATION` fires on entirely benign prompts, so this is reachable on ordinary runs.
|
|
395
|
+
- `context_exceeded` → **`result.error === 'context_exceeded'`** (ran out of context window — a spend/limit story, distinct from the output cap).
|
|
396
|
+
- `pause_turn` → the Loop **resumes** the turn (a resumable server-tool pause is neither terminal nor an error). Resuming appends the paused assistant turn (its partial text + provider-native server-tool blocks) and re-requests — the documented Anthropic pause_turn protocol, so the server continues where it left off rather than restarting. A pause that never progresses is caught by the same `HARD_ROUND_LIMIT` / gate `maxTurns` bounds as any non-advancing loop.
|
|
397
|
+
- The tool calls of a `refusal` / `context_exceeded` round are **refused, not executed** — the same BA-4 closure as `max_tokens`.
|
|
398
|
+
|
|
399
|
+
And **`result.stopReason`** (the neutral value, or `null`) is now present on **every** `Loop.run()` return, not only the clean-finish path — branch on it when you need *why* a run ended, not just *whether* it succeeded. **`error` stays the sole success signal** (`result.error === null`), and this is deliberately error-tagged rather than "surface `stopReason` only": `recurse` and any consumer following that invariant branch on `error`, so a refused worker now propagates an honest `{ incomplete }` up the tree with **zero recurse changes**. **Behavior change to note:** a refused round that previously returned `error: null` + empty text now returns `error: 'refusal'` — the point of the fix, but branch on `error`, not emptiness.
|
|
392
400
|
|
|
393
401
|
**Deny-spin short-circuit (`maxConsecutiveDenials`, default 3, v0.25+).** A *non-halt* deny (a `policy` verdict that isn't `true` — e.g. a `humanChannel: deny`, an allowlist miss, a `content`/`fs.writeScope` block) is **advisory**: it's fed back to the model as a tool result so the model can pivot to a different allowed tool. But a model that keeps retrying the *same* denied action would otherwise spin every round until your `budget.maxCostUsd` finally halts it — burning the whole cap with no progress (this bit a coding agent whose write kept tripping `content.askPatterns`). The Loop now counts **consecutive** denials (any allowed call resets the streak, preserving the pivot) and short-circuits at `maxConsecutiveDenials` with `result.error === 'denied:<tool>'` (a clean return, transcript sealed — never a throw). Check `result.error?.startsWith('denied:')` to distinguish a governance block from a completed run; set `maxConsecutiveDenials: 0` (or `Infinity`) on `new Loop({...})` to restore the pure-advisory behavior. Under `recurse`, a short-circuited worker returns a **labeled** `{ incomplete: true, blocker: 'governance-deny' }` (and `receipts.blocker`) so you can widen scope / re-gate / escalate rather than read it as a model failure.
|
|
394
402
|
|
package/package.json
CHANGED
package/src/loop.d.ts
CHANGED
|
@@ -136,7 +136,7 @@ export class Loop {
|
|
|
136
136
|
* thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
|
|
137
137
|
* unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
|
|
138
138
|
* @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
|
|
139
|
-
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
|
|
139
|
+
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
|
|
140
140
|
* On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
|
|
141
141
|
* thrown HaltError carried no `rule`), and `msgs` is sanitized so any
|
|
142
142
|
* dangling assistant `tool_calls` from the halted round are paired with
|
|
@@ -153,6 +153,13 @@ export class Loop {
|
|
|
153
153
|
*
|
|
154
154
|
* A caller-initiated `stop()` returns `error: null` — a deliberate stop is not a fault. (It previously
|
|
155
155
|
* fell through to the hard-round-limit return and reported that safety warning as its `error`.)
|
|
156
|
+
*
|
|
157
|
+
* BA-13 — `stopReason` (the round's NEUTRAL stop reason) is surfaced on EVERY return, and non-clean
|
|
158
|
+
* terminal rounds are error-tagged instead of laundered into `error: null`: a safety `refusal` returns
|
|
159
|
+
* `error: 'refusal'` and a `context_exceeded` returns `error: 'context_exceeded'`, both with partial
|
|
160
|
+
* text preserved (BA-5). BEHAVIOR CHANGE: a refused round that previously returned `error: null` with
|
|
161
|
+
* empty text now returns `error: 'refusal'` — the point of the fix. `pause_turn` is NOT terminal: the
|
|
162
|
+
* loop resumes (bounded by the hard round limit / gate). `error` stays the sole success signal.
|
|
156
163
|
* @throws {Error} `[Loop] Tool is missing a name` — when a tool has no name or a non-string name.
|
|
157
164
|
* @throws {Error} `[Loop] Tool "X" is missing an execute() function` — when execute is not a function.
|
|
158
165
|
* @throws {Error} `[Loop] Tool "X" has invalid parameters` — when parameters is not an object.
|
|
@@ -163,6 +170,7 @@ export class Loop {
|
|
|
163
170
|
usage: Usage;
|
|
164
171
|
cost: number;
|
|
165
172
|
error: string | null;
|
|
173
|
+
stopReason: string | null;
|
|
166
174
|
msgs: Message[];
|
|
167
175
|
metrics: RunMetrics;
|
|
168
176
|
temperatureDropped?: boolean;
|
|
@@ -193,7 +201,7 @@ export class Loop {
|
|
|
193
201
|
* @param {string} text - User message.
|
|
194
202
|
* @param {ToolDef[]} [tools=[]] - Tool definitions.
|
|
195
203
|
* @param {Record<string, any>} [options={}] - Per-run overrides.
|
|
196
|
-
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
|
|
204
|
+
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
|
|
197
205
|
*/
|
|
198
206
|
chat(text: string, tools?: ToolDef[], options?: Record<string, any>): Promise<{
|
|
199
207
|
text: string;
|
|
@@ -201,6 +209,7 @@ export class Loop {
|
|
|
201
209
|
usage: Usage;
|
|
202
210
|
cost: number;
|
|
203
211
|
error: string | null;
|
|
212
|
+
stopReason: string | null;
|
|
204
213
|
msgs: Message[];
|
|
205
214
|
metrics: RunMetrics;
|
|
206
215
|
temperatureDropped?: boolean;
|
package/src/loop.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { ToolError, HaltError } = require('./errors');
|
|
4
|
-
const {
|
|
4
|
+
const { classifyStopReason } = require('./provider-stop-reason');
|
|
5
5
|
|
|
6
6
|
/** @typedef {import('../types').Provider} Provider */
|
|
7
7
|
/** @typedef {import('../types').Message} Message */
|
|
@@ -346,7 +346,7 @@ class Loop {
|
|
|
346
346
|
* thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
|
|
347
347
|
* unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
|
|
348
348
|
* @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
|
|
349
|
-
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
|
|
349
|
+
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
|
|
350
350
|
* On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
|
|
351
351
|
* thrown HaltError carried no `rule`), and `msgs` is sanitized so any
|
|
352
352
|
* dangling assistant `tool_calls` from the halted round are paired with
|
|
@@ -363,6 +363,13 @@ class Loop {
|
|
|
363
363
|
*
|
|
364
364
|
* A caller-initiated `stop()` returns `error: null` — a deliberate stop is not a fault. (It previously
|
|
365
365
|
* fell through to the hard-round-limit return and reported that safety warning as its `error`.)
|
|
366
|
+
*
|
|
367
|
+
* BA-13 — `stopReason` (the round's NEUTRAL stop reason) is surfaced on EVERY return, and non-clean
|
|
368
|
+
* terminal rounds are error-tagged instead of laundered into `error: null`: a safety `refusal` returns
|
|
369
|
+
* `error: 'refusal'` and a `context_exceeded` returns `error: 'context_exceeded'`, both with partial
|
|
370
|
+
* text preserved (BA-5). BEHAVIOR CHANGE: a refused round that previously returned `error: null` with
|
|
371
|
+
* empty text now returns `error: 'refusal'` — the point of the fix. `pause_turn` is NOT terminal: the
|
|
372
|
+
* loop resumes (bounded by the hard round limit / gate). `error` stays the sole success signal.
|
|
366
373
|
* @throws {Error} `[Loop] Tool is missing a name` — when a tool has no name or a non-string name.
|
|
367
374
|
* @throws {Error} `[Loop] Tool "X" is missing an execute() function` — when execute is not a function.
|
|
368
375
|
* @throws {Error} `[Loop] Tool "X" has invalid parameters` — when parameters is not an object.
|
|
@@ -427,6 +434,12 @@ class Loop {
|
|
|
427
434
|
// The caller decides what a partial result is worth; the library must not decide it is worth nothing.
|
|
428
435
|
// Stays '' when the model never produced text (nothing to preserve — we never invent a placeholder).
|
|
429
436
|
let lastText = '';
|
|
437
|
+
// BA-13: the NEUTRAL stop reason of the most recent completed round (post-provider-normalization).
|
|
438
|
+
// Surfaced on EVERY return so a caller can branch on WHY a run ended, not just its `error` tag — the
|
|
439
|
+
// load-bearing companion to the classifier (a terminal `error` says "not a clean finish"; `stopReason`
|
|
440
|
+
// says which kind). Stays null until the first round completes, and across a provider error / a
|
|
441
|
+
// pre-round stop() it holds the last round's value (or null if none ran).
|
|
442
|
+
let lastStopReason = null;
|
|
430
443
|
// BA-10: sticky across rounds — true if ANY round's `temperature` was dropped by the model (400,
|
|
431
444
|
// unsupported/deprecated) and retried without it. Surfaced on the result so an upstream receipt
|
|
432
445
|
// (recurse's refineLeaf) can report the EFFECTIVE temperature rather than the ignored request.
|
|
@@ -463,7 +476,7 @@ class Loop {
|
|
|
463
476
|
sealDanglingToolCalls(msgs, `[halted:${stuckTag}]`);
|
|
464
477
|
this._reportError('stuck', new Error(`tool "${tc.name}" failed ${identicalErrors} times with identical arguments`), { rule: stuckTag, attempts: identicalErrors });
|
|
465
478
|
this._safeEmit({ type: 'loop:done', data: { text: lastText, stuck: true, rule: stuckTag, cost: totalCost } });
|
|
466
|
-
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: stuckTag, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
479
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: stuckTag, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
467
480
|
}
|
|
468
481
|
return null;
|
|
469
482
|
};
|
|
@@ -691,7 +704,7 @@ class Loop {
|
|
|
691
704
|
this._reportError('provider', err, { round });
|
|
692
705
|
if (this.throwOnError) throw err;
|
|
693
706
|
// BA-5: a mid-run provider failure must not erase the work of the rounds that succeeded.
|
|
694
|
-
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
707
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
695
708
|
}
|
|
696
709
|
|
|
697
710
|
lastUsage = result.usage || lastUsage;
|
|
@@ -700,6 +713,9 @@ class Loop {
|
|
|
700
713
|
// is the text most worth keeping). A tool-call-only round carries no text, so hold the last non-empty
|
|
701
714
|
// one rather than letting a silent round erase an earlier account.
|
|
702
715
|
if (typeof result.text === 'string' && result.text.trim() !== '') lastText = result.text;
|
|
716
|
+
// BA-13: capture this round's neutral stop reason for surfacing on the run's return (every exit
|
|
717
|
+
// path reads lastStopReason). Non-string / absent ⇒ null (the provider's pre-BA-6 degrade).
|
|
718
|
+
lastStopReason = typeof result.stopReason === 'string' ? result.stopReason : null;
|
|
703
719
|
if (result.temperatureDropped) temperatureDropped = true;
|
|
704
720
|
// Publish the latest measured usage to ctx (non-enumerable, fail-open) so a transcript-bound seam —
|
|
705
721
|
// e.g. F2 stash auto-compaction — can read EXACT provider-counted `inputTokens` to gauge context
|
|
@@ -745,21 +761,48 @@ class Loop {
|
|
|
745
761
|
}
|
|
746
762
|
}
|
|
747
763
|
|
|
748
|
-
// BA-
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
// so refusing here discards nothing legitimate and closes the data-loss path for EVERY tool.
|
|
764
|
+
// BA-13: classify this round's terminal signal against the neutral stop-reason vocabulary. BA-6
|
|
765
|
+
// short-circuited exactly one non-clean reason (max_tokens); this gate is the general form. It sits
|
|
766
|
+
// AFTER metering (the tokens were really spent — the gate must see them) and BEFORE tool execution,
|
|
767
|
+
// which is the load-bearing half: a non-final round's tool calls were cut off mid-generation, so
|
|
768
|
+
// their arguments are missing keys. Executing one is exactly how BA-4 emptied a 1789-line file
|
|
769
|
+
// (`shell_write` reached the fs with no `content`). A COMPLETE call always arrives tagged 'tool_use',
|
|
770
|
+
// never a truncation/refusal (measured on the real API, poc/ba6-stop-reason-mapping.mjs), so refusing
|
|
771
|
+
// the tool calls of ANY non-clean terminal round discards nothing legitimate and closes the data-loss
|
|
772
|
+
// path for EVERY tool — the BA-4 protocol-layer closure applied uniformly (see classifyStopReason).
|
|
758
773
|
//
|
|
759
|
-
// We report; the caller decides. No auto-retry
|
|
760
|
-
//
|
|
761
|
-
//
|
|
762
|
-
|
|
774
|
+
// We report; the caller decides. No auto-retry: that doubles spend against the gate's budget, and the
|
|
775
|
+
// right recovery (raise the cap? re-gate the refusal? shorten the context?) is the caller's, not the
|
|
776
|
+
// library's. `lastText` (BA-5) preserves the partial work on every terminal leg.
|
|
777
|
+
const terminal = classifyStopReason(result.stopReason);
|
|
778
|
+
if (terminal === 'resume') {
|
|
779
|
+
// BA-13: `pause_turn` — a RESUMABLE server-side tool pause (the API's server-tool loop hit its
|
|
780
|
+
// per-turn iteration cap mid-turn). NOT a finish, NOT an error. Resuming REQUIRES re-sending the
|
|
781
|
+
// paused assistant turn: the provider detects the trailing server-tool block and continues where
|
|
782
|
+
// it left off (the documented Anthropic pause_turn protocol — "re-send the user message and
|
|
783
|
+
// assistant response"). A bare `continue` WITHOUT appending re-sends byte-identical input, so the
|
|
784
|
+
// server restarts the turn from scratch and pauses again, spinning to HARD_ROUND_LIMIT (100 paid
|
|
785
|
+
// calls) instead of resuming. So append the assistant turn — its partial text plus the
|
|
786
|
+
// provider-native server-tool/thinking blocks (which the Anthropic provider replays via
|
|
787
|
+
// providerBlocks) — BEFORE continuing, exactly as the tool-execution path pushes its assistant
|
|
788
|
+
// turn. Only push when there is something to carry: an empty assistant turn (no text, no blocks)
|
|
789
|
+
// is wire-invalid, and a pause with no partial output cannot be advanced anyway — HARD_ROUND_LIMIT
|
|
790
|
+
// (or the gate) bounds that pathological case. No client tool_calls are pushed: a server pause
|
|
791
|
+
// does not carry an unpaired client call, and pushing one would orphan it (wire-invalid) — the
|
|
792
|
+
// same BA-4 refusal principle as the other non-clean terminal legs.
|
|
793
|
+
const hasText = typeof result.text === 'string' && result.text.trim() !== '';
|
|
794
|
+
if (hasText || result.providerBlocks) {
|
|
795
|
+
msgs.push({ role: 'assistant', content: result.text || null, ...(result.providerBlocks && { providerBlocks: result.providerBlocks }) });
|
|
796
|
+
}
|
|
797
|
+
this._safeEmit({ type: 'loop:resume', data: { round, stopReason: lastStopReason } });
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
if (terminal) {
|
|
801
|
+
// terminal ∈ {'truncated','refusal','context_exceeded'} — a non-clean terminal round. Error-tag it
|
|
802
|
+
// (NOT just surface stopReason): `recurse`'s worker path and the bareloop adopter both branch on
|
|
803
|
+
// `error`, and 0.27.0's "error is the sole success signal" invariant must stay true — an
|
|
804
|
+
// `error:null` + `stopReason:'refusal'` would re-breed BA-6 for these legs.
|
|
805
|
+
const errorTag = terminal === 'truncated' ? 'truncated:max_tokens' : terminal;
|
|
763
806
|
const dropped = (result.toolCalls || []).length;
|
|
764
807
|
// Seal the transcript with the partial text only. Deliberately NOT the tool_calls: pushing a call
|
|
765
808
|
// we refuse to execute would orphan it (a tool_call with no tool_result is a wire-invalid
|
|
@@ -767,8 +810,8 @@ class Loop {
|
|
|
767
810
|
if (typeof result.text === 'string' && result.text.trim() !== '') {
|
|
768
811
|
msgs.push({ role: 'assistant', content: result.text });
|
|
769
812
|
}
|
|
770
|
-
this._safeEmit({ type: 'loop:done', data: { text: lastText, truncated: true, droppedToolCalls: dropped, cost: totalCost } });
|
|
771
|
-
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error:
|
|
813
|
+
this._safeEmit({ type: 'loop:done', data: { text: lastText, ...(terminal === 'truncated' && { truncated: true }), terminal, stopReason: lastStopReason, droppedToolCalls: dropped, cost: totalCost } });
|
|
814
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: errorTag, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
772
815
|
}
|
|
773
816
|
|
|
774
817
|
// No tool calls — LLM gave a final text response
|
|
@@ -788,7 +831,7 @@ class Loop {
|
|
|
788
831
|
try { await flush(msgs, ctx); }
|
|
789
832
|
catch (err) { if (err instanceof HaltError) throw err; this._reportError('trim-flush', err, { round }); }
|
|
790
833
|
}
|
|
791
|
-
return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
834
|
+
return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
792
835
|
}
|
|
793
836
|
|
|
794
837
|
// Execute tool calls
|
|
@@ -897,7 +940,7 @@ class Loop {
|
|
|
897
940
|
sealDanglingToolCalls(msgs, `[halted:${denyTag}]`);
|
|
898
941
|
this._reportError('denied', new Error(`policy denied ${consecutiveDenials} consecutive tool calls (${tc.name})`), { rule: denyTag, denials: consecutiveDenials });
|
|
899
942
|
this._safeEmit({ type: 'loop:done', data: { text: lastText, denied: true, rule: denyTag, cost: totalCost } });
|
|
900
|
-
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
943
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
901
944
|
}
|
|
902
945
|
continue;
|
|
903
946
|
}
|
|
@@ -971,7 +1014,7 @@ class Loop {
|
|
|
971
1014
|
// BA-5: the rule tag survives on `error`; so does the work. A halt is how a bounded attempt is
|
|
972
1015
|
// SUPPOSED to end — the caller reads `error` to know it was bounded and `text` to learn from it.
|
|
973
1016
|
this._safeEmit({ type: 'loop:done', data: { text: lastText, halted: true, rule, cost: totalCost } });
|
|
974
|
-
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
1017
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
975
1018
|
}
|
|
976
1019
|
throw err;
|
|
977
1020
|
}
|
|
@@ -1001,20 +1044,20 @@ class Loop {
|
|
|
1001
1044
|
const rule = err.rule || 'unknown';
|
|
1002
1045
|
this._reportError('halt', err, { rule, reason: err.decision?.reason ?? null });
|
|
1003
1046
|
this._safeEmit({ type: 'loop:done', data: { text: lastText, halted: true, rule, cost: totalCost } });
|
|
1004
|
-
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
1047
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
1005
1048
|
}
|
|
1006
1049
|
this._reportError('trim-flush', err, { phase: 'stop' });
|
|
1007
1050
|
}
|
|
1008
1051
|
}
|
|
1009
1052
|
this._safeEmit({ type: 'loop:done', data: { text: lastText, stopped: true, cost: totalCost } });
|
|
1010
|
-
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
1053
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
1011
1054
|
}
|
|
1012
1055
|
|
|
1013
1056
|
// Hard safety limit — should never fire under normal usage; bareguard's
|
|
1014
1057
|
// limits.maxTurns (or the LLM's natural completion) ends the loop first.
|
|
1015
1058
|
const warning = `[Loop] hit internal safety limit of ${HARD_ROUND_LIMIT} rounds. Wire bareguard for proper governance — see bare-agent/bareguard.`;
|
|
1016
1059
|
this._safeEmit({ type: 'loop:done', data: { text: lastText, warning, cost: totalCost } });
|
|
1017
|
-
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
1060
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
1018
1061
|
}
|
|
1019
1062
|
|
|
1020
1063
|
/**
|
|
@@ -1087,7 +1130,7 @@ class Loop {
|
|
|
1087
1130
|
* @param {string} text - User message.
|
|
1088
1131
|
* @param {ToolDef[]} [tools=[]] - Tool definitions.
|
|
1089
1132
|
* @param {Record<string, any>} [options={}] - Per-run overrides.
|
|
1090
|
-
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
|
|
1133
|
+
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
|
|
1091
1134
|
*/
|
|
1092
1135
|
async chat(text, tools = [], options = {}) {
|
|
1093
1136
|
this._history.push({ role: 'user', content: text });
|
|
@@ -20,7 +20,15 @@ export function normalizeStopReason(raw: string | null | undefined, provider: "a
|
|
|
20
20
|
* folded in here — `pause_turn` in particular is a RESUMABLE state, and erroring on it would break
|
|
21
21
|
* server-side tool flows that are working exactly as designed.
|
|
22
22
|
*
|
|
23
|
+
* Retained for back-compat (it was the Loop's original BA-6 gate). The Loop now routes through
|
|
24
|
+
* {@link classifyStopReason} instead — `isTruncated(x)` is exactly `classifyStopReason(x) === 'truncated'`.
|
|
25
|
+
*
|
|
23
26
|
* @param {string|null|undefined} stopReason - a NEUTRAL value (post-{@link normalizeStopReason}).
|
|
24
27
|
* @returns {boolean}
|
|
25
28
|
*/
|
|
26
29
|
export function isTruncated(stopReason: string | null | undefined): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* @param {string|null|undefined} stopReason - a NEUTRAL value (post-{@link normalizeStopReason}).
|
|
32
|
+
* @returns {'truncated'|'refusal'|'context_exceeded'|'resume'|null}
|
|
33
|
+
*/
|
|
34
|
+
export function classifyStopReason(stopReason: string | null | undefined): "truncated" | "refusal" | "context_exceeded" | "resume" | null;
|
|
@@ -111,7 +111,10 @@ function normalizeStopReason(raw, provider, ctx = {}) {
|
|
|
111
111
|
if (!table) return raw;
|
|
112
112
|
// An unrecognized-but-present value passes through: the caller can still SEE it, and the Loop only
|
|
113
113
|
// acts on the known vocabulary — so a new upstream value can never be mistaken for a truncation.
|
|
114
|
-
|
|
114
|
+
// Own-property only (mirror of classifyStopReason): `raw` is a provider/proxy-supplied field, so a
|
|
115
|
+
// value like 'toString'/'constructor' would otherwise resolve `table[raw]` to an inherited
|
|
116
|
+
// Object.prototype function (truthy) and be returned in place of the verbatim string.
|
|
117
|
+
const mapped = Object.prototype.hasOwnProperty.call(table, raw) ? table[raw] : raw;
|
|
115
118
|
|
|
116
119
|
// GEMINI AND OLLAMA HAVE NO `tool_use` FINISH REASON (both measured live: Gemini returns
|
|
117
120
|
// `finishReason: STOP` and Ollama `done_reason: 'stop'` on a round that emitted a complete function
|
|
@@ -138,6 +141,9 @@ function normalizeStopReason(raw, provider, ctx = {}) {
|
|
|
138
141
|
* folded in here — `pause_turn` in particular is a RESUMABLE state, and erroring on it would break
|
|
139
142
|
* server-side tool flows that are working exactly as designed.
|
|
140
143
|
*
|
|
144
|
+
* Retained for back-compat (it was the Loop's original BA-6 gate). The Loop now routes through
|
|
145
|
+
* {@link classifyStopReason} instead — `isTruncated(x)` is exactly `classifyStopReason(x) === 'truncated'`.
|
|
146
|
+
*
|
|
141
147
|
* @param {string|null|undefined} stopReason - a NEUTRAL value (post-{@link normalizeStopReason}).
|
|
142
148
|
* @returns {boolean}
|
|
143
149
|
*/
|
|
@@ -145,4 +151,50 @@ function isTruncated(stopReason) {
|
|
|
145
151
|
return stopReason === 'max_tokens';
|
|
146
152
|
}
|
|
147
153
|
|
|
148
|
-
|
|
154
|
+
/**
|
|
155
|
+
* BA-13 — classify a round's NEUTRAL stop reason into the terminal ACTION the Loop must take.
|
|
156
|
+
*
|
|
157
|
+
* BA-6 short-circuited exactly one non-clean stop reason (`max_tokens`). Every OTHER non-clean reason
|
|
158
|
+
* — `refusal`, `context_exceeded`, `pause_turn` — fell through the Loop's "no tool calls ⇒ final
|
|
159
|
+
* answer" rule and was laundered into a clean `error: null` empty success (the BA-4/5/6/7 bug class:
|
|
160
|
+
* an under-modeled boundary round rounding optimistically toward "done"). A `RECITATION` refusal fires
|
|
161
|
+
* on entirely BENIGN prompts, so this was reachable on ordinary runs, and it propagated up `recurse`'s
|
|
162
|
+
* agent tree as a converged sub-task.
|
|
163
|
+
*
|
|
164
|
+
* One table with an EXPLICIT pass-through default replaces the single `if (isTruncated)` — the BA-7
|
|
165
|
+
* lesson ("don't parse-key on a closed set") applied to termination: BA-6 added one leg, BA-13 adds
|
|
166
|
+
* two terminals plus one resume, and the NEXT new stop reason degrades to pass-through (status quo)
|
|
167
|
+
* rather than re-breeding the bug.
|
|
168
|
+
*
|
|
169
|
+
* 'truncated' `max_tokens` — cut off at the output cap (BA-6). Loop returns `error:'truncated:max_tokens'`.
|
|
170
|
+
* 'refusal' declined on safety grounds. Loop returns `error:'refusal'` + partial text.
|
|
171
|
+
* 'context_exceeded' ran out of context window. Loop returns `error:'context_exceeded'` + partial text.
|
|
172
|
+
* 'resume' `pause_turn` — a RESUMABLE server-tool pause. NOT terminal, NOT an error: the
|
|
173
|
+
* Loop CONTINUES the round loop (bounded by HARD_ROUND_LIMIT / the gate's maxTurns).
|
|
174
|
+
* null pass-through: `end_turn` / `stop_sequence` / `tool_use` / an unrecognized value /
|
|
175
|
+
* absent. The Loop's existing tool-exec / final-answer logic runs unchanged.
|
|
176
|
+
*
|
|
177
|
+
* NB: `tool_use` is already derived by {@link normalizeStopReason} from what the round carried, so it
|
|
178
|
+
* needs no row here — a round that stopped to call a complete tool passes through to tool execution.
|
|
179
|
+
*/
|
|
180
|
+
const TERMINAL_ACTIONS = /** @type {Record<string, 'truncated'|'refusal'|'context_exceeded'|'resume'>} */ ({
|
|
181
|
+
max_tokens: 'truncated',
|
|
182
|
+
refusal: 'refusal',
|
|
183
|
+
context_exceeded: 'context_exceeded',
|
|
184
|
+
pause_turn: 'resume',
|
|
185
|
+
});
|
|
186
|
+
/**
|
|
187
|
+
* @param {string|null|undefined} stopReason - a NEUTRAL value (post-{@link normalizeStopReason}).
|
|
188
|
+
* @returns {'truncated'|'refusal'|'context_exceeded'|'resume'|null}
|
|
189
|
+
*/
|
|
190
|
+
function classifyStopReason(stopReason) {
|
|
191
|
+
if (typeof stopReason !== 'string') return null;
|
|
192
|
+
// Own-property only: `normalizeStopReason` passes an unrecognized value through verbatim, so a
|
|
193
|
+
// provider/proxy emitting stop_reason:'toString'/'constructor'/etc. would otherwise resolve to an
|
|
194
|
+
// inherited Object.prototype function (truthy) and be mistaken for a terminal action.
|
|
195
|
+
return Object.prototype.hasOwnProperty.call(TERMINAL_ACTIONS, stopReason)
|
|
196
|
+
? TERMINAL_ACTIONS[stopReason]
|
|
197
|
+
: null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = { normalizeStopReason, isTruncated, classifyStopReason };
|
|
@@ -143,7 +143,7 @@ export function buildScanTool(corpus: Slice[] | (() => Promise<Slice[]>), opts:
|
|
|
143
143
|
/**
|
|
144
144
|
* Build a SCAN slice-source backed by a litectx-RESIDENT corpus (facts/episodes the agent already accrued) —
|
|
145
145
|
* the §10-step-7 deferral un-blocked by litectx 0.26's `enumerate` verb (spec:
|
|
146
|
-
* docs/01-product/
|
|
146
|
+
* docs/01-product/prd.md). Returns the generic async slice-source recurse's scan reads: a
|
|
147
147
|
* `() => Promise<Slice[]>` that pages through EVERY row of `kind` via `enumerate` (exhaustive — the rank-free
|
|
148
148
|
* read `recall` structurally cannot do) and maps each to `{id: item.path, text: item.body}`. recurse stays
|
|
149
149
|
* litectx-agnostic: it depends on this source SHAPE, never on litectx (same stance as `remember`'s Store
|
package/src/recurse-retrieval.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
//
|
|
17
17
|
// THE CORPUS FOR SCAN IS A GENERIC ARRAY SLICE-SOURCE (`opts.corpus`), NOT litectx: litectx has no exhaustive,
|
|
18
18
|
// rank-free enumerate verb today (every read is FTS-gated). The "corpus that already LIVES in litectx" case
|
|
19
|
-
// waits on the litectx `enumerate` verb (docs/01-product/
|
|
19
|
+
// waits on the litectx `enumerate` verb (docs/01-product/prd.md) and drops in behind this
|
|
20
20
|
// same slice-source socket with ZERO recurse changes — the same backend-agnostic stance as `remember`'s Store
|
|
21
21
|
// socket. Composes AROUND a Loop; NEVER imported by loop.js.
|
|
22
22
|
|
|
@@ -326,7 +326,7 @@ const ENUM_PAGE = 200;
|
|
|
326
326
|
/**
|
|
327
327
|
* Build a SCAN slice-source backed by a litectx-RESIDENT corpus (facts/episodes the agent already accrued) —
|
|
328
328
|
* the §10-step-7 deferral un-blocked by litectx 0.26's `enumerate` verb (spec:
|
|
329
|
-
* docs/01-product/
|
|
329
|
+
* docs/01-product/prd.md). Returns the generic async slice-source recurse's scan reads: a
|
|
330
330
|
* `() => Promise<Slice[]>` that pages through EVERY row of `kind` via `enumerate` (exhaustive — the rank-free
|
|
331
331
|
* read `recall` structurally cannot do) and maps each to `{id: item.path, text: item.body}`. recurse stays
|
|
332
332
|
* litectx-agnostic: it depends on this source SHAPE, never on litectx (same stance as `remember`'s Store
|
package/src/recurse.js
CHANGED
|
@@ -708,7 +708,7 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
|
|
|
708
708
|
* never folded into the count as a zero; a governance HaltError mid-scan → clean incomplete.
|
|
709
709
|
*
|
|
710
710
|
* The corpus is the generic array slice-source `opts.corpus`. Absent it, scan has nothing to read — litectx's
|
|
711
|
-
* resident-corpus enumerate path is deferred (docs/01-product/
|
|
711
|
+
* resident-corpus enumerate path is deferred (docs/01-product/prd.md) — so we return an
|
|
712
712
|
* honest incomplete, never a fabricated zero.
|
|
713
713
|
* @param {string} task
|
|
714
714
|
* @param {RecurseCtx} ctx
|