@getpipher/armory-fleet 0.13.0 → 0.15.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 +39 -8
- package/package.json +1 -1
- package/src/backend/claude-events.ts +32 -13
- package/src/backend/claude-session.ts +10 -10
- package/src/engine/auto-fallback.ts +19 -0
- package/src/engine/retry-fallback.ts +8 -1
- package/src/engine/spawnSubagent.ts +41 -8
- package/src/index.ts +41 -2
- package/src/panel/fleet-panel.ts +10 -18
- package/src/runtime/run-log.ts +6 -0
- package/src/tiers/builtin.ts +14 -5
- package/src/tiers/resolve.ts +14 -0
- package/src/tools/subagent.ts +30 -1
package/README.md
CHANGED
|
@@ -123,22 +123,22 @@ export const meta = {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
phase('Plan')
|
|
126
|
-
const plan = await agent('Plan this feature: ' + args.task, { tier: '
|
|
126
|
+
const plan = await agent('Plan this feature: ' + args.task, { tier: 'economy' })
|
|
127
127
|
|
|
128
128
|
phase('Implement')
|
|
129
|
-
const impl = await agent(`Implement the plan:\n${plan}`, { tier: '
|
|
129
|
+
const impl = await agent(`Implement the plan:\n${plan}`, { tier: 'standard' })
|
|
130
130
|
|
|
131
131
|
phase('Review')
|
|
132
132
|
const angles = ['security', 'performance', 'correctness']
|
|
133
133
|
const reviews = await parallel(
|
|
134
|
-
angles.map((a) => () => agent(`Review the implementation for ${a} issues.`, { tier: '
|
|
134
|
+
angles.map((a) => () => agent(`Review the implementation for ${a} issues.`, { tier: 'economy' })),
|
|
135
135
|
)
|
|
136
136
|
|
|
137
137
|
// gate: revise the synthesis until it passes a validator
|
|
138
138
|
const synthesis = await gate(
|
|
139
139
|
async (_feedback, n) => n === 0
|
|
140
|
-
? agent(`Synthesize ${reviews.length} reviews.`, { tier: '
|
|
141
|
-
: agent('Revise synthesis per feedback.', { tier: '
|
|
140
|
+
? agent(`Synthesize ${reviews.length} reviews.`, { tier: 'economy' })
|
|
141
|
+
: agent('Revise synthesis per feedback.', { tier: 'economy' }),
|
|
142
142
|
(v) => typeof v === 'string' && v.length > 200 ? { ok: true } : { ok: false, feedback: 'more detail' },
|
|
143
143
|
{ attempts: 3 },
|
|
144
144
|
)
|
|
@@ -236,9 +236,21 @@ Composite helpers (`src/workflows/helpers/`) — usable from any workflow:
|
|
|
236
236
|
|
|
237
237
|
| Tier | Models | Cost cap | Context floor |
|
|
238
238
|
|---|---|---|---|
|
|
239
|
-
| `economy` | `
|
|
240
|
-
| `standard` | `
|
|
241
|
-
| `frontier` | `
|
|
239
|
+
| `economy` | `inherit` | — | — |
|
|
240
|
+
| `standard` | `inherit` | — | — |
|
|
241
|
+
| `frontier` | `inherit` | — | 200k ctx |
|
|
242
|
+
|
|
243
|
+
The shipped defaults use the **`inherit` sentinel** — each tier resolves to your **active session model**, so tier routing works on any provider out of the box. To route across models, override a tier by name with a concrete `provider/id` chain in `~/.pi/agent/fleet/tiers.json` (global) or `<project>/.pi/fleet/tiers.json` (project):
|
|
244
|
+
|
|
245
|
+
```json
|
|
246
|
+
[
|
|
247
|
+
{ "name": "economy", "models": ["Ollama/minimax-m3:cloud"] },
|
|
248
|
+
{ "name": "standard", "models": ["Ollama/glm-5.2:cloud", "inherit"] },
|
|
249
|
+
{ "name": "frontier", "models": ["anthropic/claude-sonnet-4"], "costCap": 5, "contextFloor": 200000 }
|
|
250
|
+
]
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Models are an ordered fallback chain (primary first; a spawn retries the next candidate if model creation is rejected); the `inherit` sentinel (case-insensitive) may appear anywhere in the chain as a provider-agnostic fallback and always resolves to the session model without catalog or floor checks. `contextFloor` skips catalog models below the window size; `costCap` aborts a run whose live cost exceeds the cap (a no-op on flat subscriptions). Tier routing applies to pi-backend agents — `backend: "claude"` agents receive the resolved string via `--model` and the claude CLI expects its own model names, so route those by `model:` instead.
|
|
242
254
|
|
|
243
255
|
Live cost $ and context % are tracked per run and surfaced in the Tiers view. Override per-run with `model`, or let the tier registry route based on the task class.
|
|
244
256
|
|
|
@@ -316,6 +328,25 @@ Every workflow run is **journaled** (`workflows/journal.ts`) and **resumable**.
|
|
|
316
328
|
- **Panel Run-action:** a 3rd `cwd` input step (task → name → cwd), prefilled with the session cwd; Enter accepts, Escape cancels.
|
|
317
329
|
- **Deferred:** bg/scheduled + worktree cwd-isolation (the `cwd` param is honored by foreground dispatches only for now) — tracked in #62.
|
|
318
330
|
|
|
331
|
+
## Provider-agnostic tiers (v0.15.0)
|
|
332
|
+
|
|
333
|
+
Small-backlog batch (issues #57/#63/#64/#65):
|
|
334
|
+
|
|
335
|
+
- **Tier `inherit` sentinel (#64)** — builtin tiers no longer hardcode a provider: `economy`/`standard`/`frontier` now resolve to your **active session model** out of the box (frontier keeps its 200k context floor; the $5 cost cap moved to the override example — a no-op on flat subs). Override by name in `tiers.json` with concrete `provider/id` chains for real multi-model routing; `inherit` can appear mid-chain as a provider-agnostic fallback. See [Cost-aware tiers](#cost-aware-tiers).
|
|
336
|
+
- **Self-correcting model errors (#57)** — dispatching with a model the runtime doesn't have now lists the session's available (authed) models in the error, so the orchestrating model can pick a valid one on the retry instead of guessing.
|
|
337
|
+
- **Panel Escape semantics documented + dead code removed (#63)** — Escape always cancels the active panel flow; defaults are accepted via Enter-on-blank. (Also fixed: ctrl+c could trigger the never-documented "escape accepts default" callbacks.)
|
|
338
|
+
- **README example tier names fixed (#65)** — the `ship-feature` example now uses real tier names (`economy`/`standard`).
|
|
339
|
+
|
|
340
|
+
## Dogfood reliability (v0.14.0)
|
|
341
|
+
|
|
342
|
+
Four fixes from dogfooding the fleet on itself (issues #58–#61):
|
|
343
|
+
|
|
344
|
+
- **`ARMORY_FLEET_MODEL_FALLBACK=auto`** — resolve the global fallback per session from the configured+available model snapshot: a different **provider** than the session model is preferred, else a different model id; unresolvable (single-model setup) stays off with a one-time warning. Non-`auto` env values are used verbatim; per-dispatch `modelFallback` still wins.
|
|
345
|
+
- **No-fallback hint** — a retryable provider failure (stopReason `error`) with neither a per-dispatch `modelFallback` nor the global default surfaces `no modelFallback configured — pass modelFallback or set ARMORY_FLEET_MODEL_FALLBACK` so silent no-retry failures are visible.
|
|
346
|
+
- **Masked primary errors fixed** — when a fallback retry also fails, the surfaced error now names **both** attempts (`primary '<model>' failed: …; fallback '<model>' failed: …`) instead of only the fallback's.
|
|
347
|
+
- **Zero-tool-call flag (#61)** — a run that "completes" without a single executed tool call (the premature-return shape: narrate a plan, end) is prefixed with `[FLEET] zero-tool-call run — likely a premature return` in the tool result; `details.toolCallCount` exposes the count. Verify (git status/log) before trusting such a result.
|
|
348
|
+
- **Richer run journal** — `run:ended` now carries `error` (failure reason), `filesTouched` (#49 parity in the durable journal — real SDK args are captured from `tool_execution_start`; the end event has none), and `toolCallCount`.
|
|
349
|
+
|
|
319
350
|
## Roadmap
|
|
320
351
|
|
|
321
352
|
armory-fleet follows a PRD → SPEC-N (brainstorm → spec → plan → implementation) pipeline. **16/16 phases done through v0.12.0.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getpipher/armory-fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The armory suite's subagent orchestrator for the pi coding agent — a cross-harness, superpowers-native fleet where every agent is armory-native from birth.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,39 +1,58 @@
|
|
|
1
|
-
// src/backend/claude-events.ts — map one CC stream-json NDJSON line → ChildSessionEvent (SPEC-3 §4.2).
|
|
1
|
+
// src/backend/claude-events.ts — map one CC stream-json NDJSON line → ChildSessionEvent(s) (SPEC-3 §4.2).
|
|
2
2
|
// Returns null for: filtered echoes (our own user writes), unknown types, malformed lines.
|
|
3
3
|
// The caller logs null-but-parseable lines at debug (forward-compat: CC may add types we don't need).
|
|
4
4
|
import type { ChildSessionEvent } from "../engine/spawnSubagent.ts";
|
|
5
5
|
|
|
6
|
-
interface
|
|
6
|
+
interface CCContentBlock { type: string; text?: string; id?: string; name?: string; input?: Record<string, unknown>; }
|
|
7
|
+
interface CCMessage { role?: string; content?: CCContentBlock[]; usage?: Record<string, unknown>; }
|
|
7
8
|
interface CCEvent { type: string; subtype?: string; session_id?: string; message?: CCMessage; error?: { message?: string }; }
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
/** Map one line to ALL the ChildSessionEvents it implies. An assistant message carrying tool_use
|
|
11
|
+
* blocks yields the message_end PLUS one tool_execution_end per block (#61: the engine's
|
|
12
|
+
* zero-tool-call premature-return signal must count claude children too — without this, every
|
|
13
|
+
* completed claude run counted 0 tools and was falsely flagged). The per-block end event fires
|
|
14
|
+
* at message time, not per-tool completion (CC stream-json gives no finer granularity) — right
|
|
15
|
+
* enough for "did the child act", which is what the count is for. */
|
|
16
|
+
export function mapClaudeEvents(line: string): ChildSessionEvent[] {
|
|
10
17
|
let ev: CCEvent;
|
|
11
18
|
try {
|
|
12
19
|
ev = JSON.parse(line) as CCEvent;
|
|
13
20
|
} catch {
|
|
14
|
-
return
|
|
21
|
+
return []; // malformed line — resilient
|
|
15
22
|
}
|
|
16
23
|
switch (ev.type) {
|
|
17
|
-
case "system":
|
|
24
|
+
case "system": {
|
|
18
25
|
if (ev.subtype === "init" && typeof ev.session_id === "string") {
|
|
19
|
-
return { type: "session_init", backendSessionId: ev.session_id };
|
|
26
|
+
return [{ type: "session_init", backendSessionId: ev.session_id }];
|
|
20
27
|
}
|
|
21
|
-
return
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
22
30
|
case "assistant": {
|
|
23
31
|
const msg = ev.message;
|
|
24
|
-
if (!msg) return
|
|
32
|
+
if (!msg) return [];
|
|
25
33
|
const content = (msg.content ?? []).map((c) => ({ type: c.type, text: c.text }));
|
|
26
34
|
const usage = msg.usage as { cost?: { total?: number } } | undefined;
|
|
27
|
-
|
|
35
|
+
const events: ChildSessionEvent[] = [{ type: "message_end", message: { role: msg.role ?? "assistant", content, usage } }];
|
|
36
|
+
for (const block of msg.content ?? []) {
|
|
37
|
+
if (block.type === "tool_use") {
|
|
38
|
+
events.push({ type: "tool_execution_end", toolCallId: block.id ?? "", toolName: block.name ?? "unknown", args: block.input, result: "", isError: false });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return events;
|
|
28
42
|
}
|
|
29
43
|
case "result":
|
|
30
44
|
// turn boundary (success or error_max_turns) → turn_end drives the budget
|
|
31
|
-
return { type: "turn_end" };
|
|
45
|
+
return [{ type: "turn_end" }];
|
|
32
46
|
case "error":
|
|
33
|
-
return { type: "error", message: { role: "error", content: [{ type: "text", text: ev.error?.message ?? "claude error" }] } };
|
|
47
|
+
return [{ type: "error", message: { role: "error", content: [{ type: "text", text: ev.error?.message ?? "claude error" }] } }];
|
|
34
48
|
case "user":
|
|
35
|
-
return
|
|
49
|
+
return []; // echo of our own stdin write — filtered
|
|
36
50
|
default:
|
|
37
|
-
return
|
|
51
|
+
return []; // unknown — forward-compat, caller logs at debug
|
|
38
52
|
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Single-event compat wrapper (claude-detector + existing consumers read one event per line). */
|
|
56
|
+
export function mapClaudeEvent(line: string): ChildSessionEvent | null {
|
|
57
|
+
return mapClaudeEvents(line)[0] ?? null;
|
|
39
58
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import type { ChildProcess } from "node:child_process";
|
|
3
3
|
import { createInterface } from "node:readline";
|
|
4
4
|
import type { ChildSession, ChildSessionEvent } from "../engine/spawnSubagent.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { mapClaudeEvents } from "./claude-events.ts";
|
|
6
6
|
import type { ResumeStore } from "./resume-store.ts";
|
|
7
7
|
|
|
8
8
|
export class ClaudeChildSession implements ChildSession {
|
|
@@ -24,16 +24,16 @@ export class ClaudeChildSession implements ChildSession {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
private onLine(line: string): void {
|
|
27
|
-
const ev
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
for (const ev of mapClaudeEvents(line)) {
|
|
28
|
+
if (ev.type === "session_init" && ev.backendSessionId && !this.initCaptured) {
|
|
29
|
+
this.initCaptured = true;
|
|
30
|
+
this.resumeStore.set("claude", this.sessionKey, ev.backendSessionId);
|
|
31
|
+
}
|
|
32
|
+
if (ev.type === "turn_end" || ev.type === "error") {
|
|
33
|
+
if (this.turnResolve) { this.turnResolve(); this.turnResolve = null; }
|
|
34
|
+
}
|
|
35
|
+
for (const h of this.handlers) h(ev);
|
|
32
36
|
}
|
|
33
|
-
if (ev.type === "turn_end" || ev.type === "error") {
|
|
34
|
-
if (this.turnResolve) { const r = this.turnResolve; this.turnResolve = null; r(); }
|
|
35
|
-
}
|
|
36
|
-
for (const h of this.handlers) h(ev);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
async prompt(text: string): Promise<void> {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// src/engine/auto-fallback.ts
|
|
2
|
+
// #58: resolve the ARMORY_FLEET_MODEL_FALLBACK=auto sentinel — pick a fallback model from the
|
|
3
|
+
// runtime's configured+available snapshot without the operator naming one. Prefers a model from
|
|
4
|
+
// a DIFFERENT provider than the session model (a real fallback family, per the "Ollama primary +
|
|
5
|
+
// OpenRouter fallback" pattern the feature was named for); if every available model shares the
|
|
6
|
+
// session's provider, a different model id on that provider. undefined when nothing differs
|
|
7
|
+
// (single-model setups) — the caller keeps auto-retry off and surfaces why.
|
|
8
|
+
|
|
9
|
+
export function resolveAutoFallback(
|
|
10
|
+
available: ReadonlyArray<{ provider: string; id: string }>,
|
|
11
|
+
parentModel: { provider: string; id: string },
|
|
12
|
+
): string | undefined {
|
|
13
|
+
const parentProvider = parentModel.provider || "";
|
|
14
|
+
const parentKey = `${parentModel.provider}/${parentModel.id}`;
|
|
15
|
+
const differentProvider = available.filter((m) => m.provider !== parentProvider);
|
|
16
|
+
const pool = differentProvider.length > 0 ? differentProvider : available;
|
|
17
|
+
const pick = pool.find((m) => `${m.provider}/${m.id}` !== parentKey);
|
|
18
|
+
return pick ? `${pick.provider}/${pick.id}` : undefined;
|
|
19
|
+
}
|
|
@@ -19,7 +19,14 @@ export function withModelFallbackRetry(spawn: SpawnFn, fallback: string | undefi
|
|
|
19
19
|
return async (opts) => {
|
|
20
20
|
const first = await spawn(opts);
|
|
21
21
|
if (first.status === "failed" && first.retryable && fallback !== first.model && !signal?.aborted) {
|
|
22
|
-
|
|
22
|
+
const second = await spawn({ ...opts, model: fallback });
|
|
23
|
+
// #59: when the fallback also fails, compose the primary's failure into the surfaced error
|
|
24
|
+
// (same contract as the direct-foreground path in tools/subagent.ts) — the fallback's error
|
|
25
|
+
// alone masks why the primary failed.
|
|
26
|
+
if (second.status === "failed" && first.error && first.error !== second.error) {
|
|
27
|
+
second.error = `primary '${first.model}' failed: ${first.error}; fallback '${fallback}' failed: ${second.error ?? second.status}`;
|
|
28
|
+
}
|
|
29
|
+
return second;
|
|
23
30
|
}
|
|
24
31
|
return first;
|
|
25
32
|
};
|
|
@@ -44,6 +44,13 @@ export interface ChildSessionEvent {
|
|
|
44
44
|
};
|
|
45
45
|
/** Emitted by a backend on session init (SPEC-3). Drives runRecord.backendSessionId. */
|
|
46
46
|
backendSessionId?: string;
|
|
47
|
+
/** tool_execution_end fields (pi SDK native; claude mapper synthesizes from tool_use blocks, #61).
|
|
48
|
+
* Consumers currently cast — these make the real shape type-visible. */
|
|
49
|
+
toolCallId?: string;
|
|
50
|
+
toolName?: string;
|
|
51
|
+
args?: unknown;
|
|
52
|
+
result?: unknown;
|
|
53
|
+
isError?: boolean;
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
export interface ChildSession {
|
|
@@ -182,6 +189,9 @@ export interface SpawnResult {
|
|
|
182
189
|
* or was it cut mid-tool-work? Surfaced on turn-budget exhaustion so the controller knows
|
|
183
190
|
* whether finalText is a partial summary or a mid-thought. Undefined for non-turn-budget paths. */
|
|
184
191
|
reachedSummary?: boolean;
|
|
192
|
+
/** #61: number of executed tool calls. A "completed" run with 0 is the premature-return
|
|
193
|
+
* shape (the child narrated and ended without acting) — the tool flags it in the result. */
|
|
194
|
+
toolCallCount?: number;
|
|
185
195
|
}
|
|
186
196
|
|
|
187
197
|
/** #49: extract file paths a tool event touched, for the structured partial-result report.
|
|
@@ -190,11 +200,13 @@ export interface SpawnResult {
|
|
|
190
200
|
function extractTouchedFiles(toolName: string, args: unknown): string[] {
|
|
191
201
|
if (!args || typeof args !== "object") return [];
|
|
192
202
|
const a = args as Record<string, unknown>;
|
|
193
|
-
|
|
203
|
+
// Case-insensitive: pi tools are lowercase ("edit"), claude's are capitalized ("Edit").
|
|
204
|
+
const name = toolName.toLowerCase();
|
|
205
|
+
if (name === "edit" || name === "write") {
|
|
194
206
|
const p = typeof a.path === "string" ? a.path : typeof a.file_path === "string" ? a.file_path : undefined;
|
|
195
207
|
return p ? [p] : [];
|
|
196
208
|
}
|
|
197
|
-
if (
|
|
209
|
+
if (name === "bash") {
|
|
198
210
|
const cmd = typeof a.command === "string" ? a.command : undefined;
|
|
199
211
|
if (!cmd) return [];
|
|
200
212
|
const out: string[] = [];
|
|
@@ -363,6 +375,12 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
363
375
|
// a trailing assistant message after its last tool (a summary) vs being cut mid-tool-work.
|
|
364
376
|
const filesTouched = new Set<string>();
|
|
365
377
|
let sawAssistantAfterLastTool = true; // no tools yet = trivially "reached a summary"
|
|
378
|
+
// #61: executed-tool count — a "completed" run with zero tool calls is the premature-return
|
|
379
|
+
// shape (read/narrate/end without acting); the tool flags it so the controller verifies.
|
|
380
|
+
let toolCallCount = 0;
|
|
381
|
+
// #60: args live on tool_execution_start (the SDK's end event carries none) — capture per
|
|
382
|
+
// toolCallId so extractTouchedFiles + the journal see the real args on real runs.
|
|
383
|
+
const pendingToolArgs = new Map<string, unknown>();
|
|
366
384
|
|
|
367
385
|
// #23: liveness — classify events into a short, content-free class string for the widget.
|
|
368
386
|
// Names the tool (safe — tool name is not args/result) so the operator sees "what's happening"
|
|
@@ -430,12 +448,20 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
430
448
|
aborted = true;
|
|
431
449
|
void session.abort();
|
|
432
450
|
}
|
|
451
|
+
} else if (e.type === "tool_execution_start") {
|
|
452
|
+
pendingToolArgs.set((e as { toolCallId?: string }).toolCallId ?? "", (e as { args?: unknown }).args); // #60
|
|
433
453
|
} else if (e.type === "tool_execution_end") {
|
|
434
454
|
// #49: track mutated files for the structured partial-result report.
|
|
455
|
+
toolCallCount += 1; // #61
|
|
456
|
+
const toolCallId = (e as { toolCallId?: string }).toolCallId ?? "";
|
|
457
|
+
// #60: real args from the start event; `?? e.args` keeps hand-rolled fakes that put args
|
|
458
|
+
// on the end event working (the SDK never does).
|
|
459
|
+
const args = pendingToolArgs.get(toolCallId) ?? (e as { args?: unknown }).args;
|
|
460
|
+
pendingToolArgs.delete(toolCallId);
|
|
435
461
|
sawAssistantAfterLastTool = false; // cut mid-tool-work unless a message follows
|
|
436
|
-
for (const f of extractTouchedFiles((e as { toolName?: string }).toolName ?? "",
|
|
462
|
+
for (const f of extractTouchedFiles((e as { toolName?: string }).toolName ?? "", args)) filesTouched.add(f);
|
|
437
463
|
try {
|
|
438
|
-
opts.runLog?.append(runId, buildToolEvent((e as any).toolName,
|
|
464
|
+
opts.runLog?.append(runId, buildToolEvent((e as any).toolName, args, (e as any).result, (e as any).isError ?? false, turnIdx));
|
|
439
465
|
} catch { /* best-effort */ }
|
|
440
466
|
}
|
|
441
467
|
// #23: liveness heartbeat — update the run record on meaningful events so the fleet widget
|
|
@@ -524,7 +550,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
524
550
|
status = "completed";
|
|
525
551
|
}
|
|
526
552
|
|
|
527
|
-
return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens, modelError ? true : undefined, filesTouchedList, reachedSummary);
|
|
553
|
+
return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens, modelError ? true : undefined, filesTouchedList, reachedSummary, toolCallCount);
|
|
528
554
|
} finally {
|
|
529
555
|
// #31: a readOnly dispatch never acquired the lock — don't release what it didn't take
|
|
530
556
|
// (releasing a lock held by another concurrent write dispatch would corrupt serialization).
|
|
@@ -547,7 +573,7 @@ async function finishRun(
|
|
|
547
573
|
status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined,
|
|
548
574
|
error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0,
|
|
549
575
|
retryable?: boolean,
|
|
550
|
-
filesTouched?: string[], reachedSummary?: boolean,
|
|
576
|
+
filesTouched?: string[], reachedSummary?: boolean, toolCallCount = 0,
|
|
551
577
|
): Promise<SpawnResult> {
|
|
552
578
|
if (finalizedRunIds.has(runId)) {
|
|
553
579
|
// Already finalized — return the existing registry record's result without re-appending.
|
|
@@ -557,7 +583,7 @@ async function finishRun(
|
|
|
557
583
|
runId, todoId, agent: agentName, model,
|
|
558
584
|
durationMs: existing?.endedAt ? existing.endedAt - startedAt : Date.now() - startedAt,
|
|
559
585
|
tokenTotal, costTotal, contextTokens, error, retryable,
|
|
560
|
-
filesTouched, reachedSummary,
|
|
586
|
+
filesTouched, reachedSummary, toolCallCount,
|
|
561
587
|
};
|
|
562
588
|
}
|
|
563
589
|
finalizedRunIds.add(runId);
|
|
@@ -574,6 +600,13 @@ async function finishRun(
|
|
|
574
600
|
type: "run:ended", runId, status, endedAt,
|
|
575
601
|
resultSummary: finalText.slice(0, 120), tokenTotal, costTotal, contextTokens,
|
|
576
602
|
resumedFrom: opts.resumeLink, forkedFrom: opts.forkLink,
|
|
603
|
+
// #59: journal the failure reason — the archived failing runs had run:ended with an empty
|
|
604
|
+
// resultSummary and no error field, making post-hoc diagnosis from the journal impossible.
|
|
605
|
+
error,
|
|
606
|
+
// #61: executed-tool count (the zero-work premature-return signal, post-hoc too).
|
|
607
|
+
toolCallCount,
|
|
608
|
+
// #60: what the run mutated (was only on the SpawnResult; the durable journal lacked it).
|
|
609
|
+
filesTouched,
|
|
577
610
|
});
|
|
578
611
|
} catch { /* best-effort: journal is the index, not the product */ }
|
|
579
612
|
// SPEC-4: lifecycle phase children skip the per-run todo reconciliation — the lifecycle
|
|
@@ -592,6 +625,6 @@ async function finishRun(
|
|
|
592
625
|
return {
|
|
593
626
|
status, finalText, runId, todoId, agent: agentName, model,
|
|
594
627
|
durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error, retryable,
|
|
595
|
-
filesTouched, reachedSummary,
|
|
628
|
+
filesTouched, reachedSummary, toolCallCount,
|
|
596
629
|
};
|
|
597
630
|
}
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { ArmoryMemoryAdapter } from "./memory-hydrate/adapter.ts";
|
|
|
18
18
|
import { ArmoryVisionAdapter } from "./vision/adapter.ts";
|
|
19
19
|
import { buildChildLoader } from "./engine/child-loader.ts";
|
|
20
20
|
import { withModelFallbackRetry } from "./engine/retry-fallback.ts";
|
|
21
|
+
import { resolveAutoFallback } from "./engine/auto-fallback.ts";
|
|
21
22
|
import { createDescribeImageTool } from "./vision/describe-image-tool.ts";
|
|
22
23
|
import type { MemoryHydratePort } from "./memory-hydrate/port.ts";
|
|
23
24
|
import type { VisionPort } from "./vision/port.ts";
|
|
@@ -108,6 +109,26 @@ async function buildDefaultBackendRegistry(modelRuntime: ModelRuntime): Promise<
|
|
|
108
109
|
return reg;
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Format the runtime's available models for the #57 self-correcting error message:
|
|
114
|
+
* dedup `provider/id` pairs, cap the list (min 1 — a non-empty input always lists
|
|
115
|
+
* something), actionable hint when empty.
|
|
116
|
+
*/
|
|
117
|
+
export function formatAvailableModels(models: readonly { provider: string; id: string }[], cap = 12): string {
|
|
118
|
+
const effectiveCap = Math.max(1, cap);
|
|
119
|
+
const seen = new Set<string>();
|
|
120
|
+
const list: string[] = [];
|
|
121
|
+
for (const m of models) {
|
|
122
|
+
const ref = `${m.provider}/${m.id}`;
|
|
123
|
+
if (seen.has(ref)) continue;
|
|
124
|
+
seen.add(ref);
|
|
125
|
+
if (list.length < effectiveCap) list.push(ref);
|
|
126
|
+
}
|
|
127
|
+
if (list.length === 0) return "(none — check provider auth / models.json)";
|
|
128
|
+
const omitted = seen.size - list.length;
|
|
129
|
+
return list.join(", ") + (omitted > 0 ? ` … +${omitted} more` : "");
|
|
130
|
+
}
|
|
131
|
+
|
|
111
132
|
export function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort: MemoryHydratePort, resumeStore: ResumeStore): ChildSessionFactory {
|
|
112
133
|
return {
|
|
113
134
|
async create(opts) {
|
|
@@ -118,7 +139,13 @@ export function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort
|
|
|
118
139
|
const provider = opts.model.slice(0, slash);
|
|
119
140
|
const id = opts.model.slice(slash + 1);
|
|
120
141
|
model = modelRuntime.getModel(provider, id);
|
|
121
|
-
|
|
142
|
+
// #57: name what IS usable so the orchestrating model can self-correct on retry.
|
|
143
|
+
if (!model) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`agent model '${opts.model}' not found in runtime (provider '${provider}', id '${id}'). ` +
|
|
146
|
+
`Available: ${formatAvailableModels(modelRuntime.getAvailableSnapshot())} — pick one of these for the model param`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
122
149
|
}
|
|
123
150
|
// Fleet CustomResourceLoader: noExtensions + composed systemPromptOverride (rolePrompt + memory + base) + scoped skills.
|
|
124
151
|
const loader = buildChildLoader({ cwd: opts.cwd, agent: opts.agent, memoryPort });
|
|
@@ -204,7 +231,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
204
231
|
// #39 tail: global default fallback model (env-driven for now; a settings.json field is a follow-up).
|
|
205
232
|
// A retryable provider failure (stopReason "error") retries once on this model even without a
|
|
206
233
|
// per-dispatch `modelFallback`. Per the AGENTS.md "Ollama primary + OpenRouter fallback" pattern.
|
|
207
|
-
|
|
234
|
+
// #39 tail + #58: the global default fallback. Non-"auto" env values are used verbatim;
|
|
235
|
+
// "auto" is resolved per-session in session_start (it needs the session model to differ from).
|
|
236
|
+
const rawModelFallback = process.env.ARMORY_FLEET_MODEL_FALLBACK || undefined;
|
|
237
|
+
deps.defaultModelFallback = rawModelFallback === "auto" ? undefined : rawModelFallback;
|
|
208
238
|
// SPEC-6-5: cross-cwd dispatch notify hook (wired per-session in session_start below).
|
|
209
239
|
// Placeholder; the real wiring happens in session_start where ctx is in scope.
|
|
210
240
|
|
|
@@ -309,6 +339,15 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
309
339
|
refreshLifecycles(ctx);
|
|
310
340
|
const m = ctx.model;
|
|
311
341
|
deps.parentModel = m ? { provider: m.provider, id: m.id } : { provider: "", id: "" };
|
|
342
|
+
// #58: ARMORY_FLEET_MODEL_FALLBACK=auto — pick a fallback from the configured+available
|
|
343
|
+
// snapshot that differs from the session model (different provider preferred). Unresolvable
|
|
344
|
+
// (single-model setup) → stay off + say why once per session.
|
|
345
|
+
if (rawModelFallback === "auto") {
|
|
346
|
+
deps.defaultModelFallback = resolveAutoFallback(modelRuntime.getAvailableSnapshot(), deps.parentModel);
|
|
347
|
+
if (!deps.defaultModelFallback) {
|
|
348
|
+
ctx.ui.notify("ARMORY_FLEET_MODEL_FALLBACK=auto, but no alternative configured model is available — auto-retry stays off", "warning");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
312
351
|
deps.parentCwd = ctx.cwd;
|
|
313
352
|
deps.onNotify = (m, k) => ctx.ui.notify(m, k ?? "info");
|
|
314
353
|
// SPEC-5a: build the per-session async runner + scheduler, start firing, scan for interrupted runs.
|
package/src/panel/fleet-panel.ts
CHANGED
|
@@ -425,10 +425,8 @@ export class FleetPanel extends Container {
|
|
|
425
425
|
this.linkInput.onSubmit = (todoIdRaw: string) => {
|
|
426
426
|
void this.executeRun(agentName, task.trim(), todoIdRaw.trim() || undefined);
|
|
427
427
|
};
|
|
428
|
-
this.linkInput.onEscape = () => { void this.executeRun(agentName, task.trim(), undefined); };
|
|
429
428
|
this.renderShell();
|
|
430
429
|
};
|
|
431
|
-
this.taskInput.onEscape = () => this.cancelRun();
|
|
432
430
|
this.runMode = true;
|
|
433
431
|
this.renderShell();
|
|
434
432
|
}
|
|
@@ -492,6 +490,12 @@ export class FleetPanel extends Container {
|
|
|
492
490
|
}
|
|
493
491
|
|
|
494
492
|
handleInput(data: string): void {
|
|
493
|
+
// Escape policy (#63): every modal branch below intercepts Escape BEFORE the active Input
|
|
494
|
+
// sees it, so pi-tui's Input.onEscape never fires in this panel — Escape always cancels
|
|
495
|
+
// the active flow. Defaults are accepted via Enter-on-blank ("blank=default" prompts).
|
|
496
|
+
// Caveat: ctrl+c also matches pi-tui's tui.select.cancel but is NOT intercepted here —
|
|
497
|
+
// it forwards to the Input, which ignores control characters (silent no-op). An onEscape
|
|
498
|
+
// callback re-added later would fire on ctrl+c but never on Escape — do not re-add.
|
|
495
499
|
if (this.infoAgent) {
|
|
496
500
|
if (matchesKey(data, "escape")) { this.infoAgent = null; this.renderShell(); }
|
|
497
501
|
return;
|
|
@@ -811,13 +815,13 @@ export class FleetPanel extends Container {
|
|
|
811
815
|
this.pendingCheckpoint = null;
|
|
812
816
|
this.renderShell();
|
|
813
817
|
};
|
|
814
|
-
this.lcReviseInput.onEscape = () => { this.lcRevising = false; this.lcReviseInput = null; this.renderShell(); };
|
|
815
818
|
this.renderShell();
|
|
816
819
|
return;
|
|
817
820
|
}
|
|
818
821
|
}
|
|
819
822
|
if (this.lcRevising && this.lcReviseInput) {
|
|
820
|
-
|
|
823
|
+
// Escape never reaches here — the panel-level intercept above resolves the pending
|
|
824
|
+
// checkpoint as abort + closes the panel first (#63). Only printable input forwards.
|
|
821
825
|
this.lcReviseInput.handleInput(data);
|
|
822
826
|
this.invalidate();
|
|
823
827
|
return;
|
|
@@ -842,13 +846,10 @@ export class FleetPanel extends Container {
|
|
|
842
846
|
const lcName = name.trim() || "default";
|
|
843
847
|
this.executeScheduleAdd(task.trim(), expr.trim(), lcName);
|
|
844
848
|
};
|
|
845
|
-
this.schedNameInput.onEscape = () => { this.executeScheduleAdd(task.trim(), expr.trim(), "default"); };
|
|
846
849
|
this.renderShell();
|
|
847
850
|
};
|
|
848
|
-
this.schedExprInput.onEscape = () => this.cancelScheduleAdd();
|
|
849
851
|
this.renderShell();
|
|
850
852
|
};
|
|
851
|
-
this.schedTaskInput.onEscape = () => this.cancelScheduleAdd();
|
|
852
853
|
this.schedRunMode = true;
|
|
853
854
|
this.renderShell();
|
|
854
855
|
}
|
|
@@ -892,7 +893,6 @@ export class FleetPanel extends Container {
|
|
|
892
893
|
if (!followUp.trim()) { this.cancelResume(); return; }
|
|
893
894
|
void this.executeResume(run, followUp.trim());
|
|
894
895
|
};
|
|
895
|
-
this.resumeInput.onEscape = () => this.cancelResume();
|
|
896
896
|
this.resumeMode = true;
|
|
897
897
|
this.renderShell();
|
|
898
898
|
}
|
|
@@ -916,7 +916,6 @@ export class FleetPanel extends Container {
|
|
|
916
916
|
if (!text.trim()) { this.cancelSteer(); return; }
|
|
917
917
|
void this.executeSteer(run.runId, text.trim());
|
|
918
918
|
};
|
|
919
|
-
this.steerInput.onEscape = () => this.cancelSteer();
|
|
920
919
|
this.steerMode = true;
|
|
921
920
|
this.renderShell();
|
|
922
921
|
}
|
|
@@ -969,7 +968,6 @@ export class FleetPanel extends Container {
|
|
|
969
968
|
}
|
|
970
969
|
this.tiersInput = new Input();
|
|
971
970
|
this.tiersInput.onSubmit = (value: string) => { void this.executeTiersEdit(value, phase); };
|
|
972
|
-
this.tiersInput.onEscape = () => this.cancelTiersEdit();
|
|
973
971
|
this.tiersEditPhase = phase;
|
|
974
972
|
this.renderShell();
|
|
975
973
|
}
|
|
@@ -995,7 +993,6 @@ export class FleetPanel extends Container {
|
|
|
995
993
|
}
|
|
996
994
|
this.cancelWorkflowRun();
|
|
997
995
|
};
|
|
998
|
-
this.wfPromptInput.onEscape = () => this.cancelWorkflowRun();
|
|
999
996
|
this.wfRunMode = true;
|
|
1000
997
|
this.renderShell();
|
|
1001
998
|
}
|
|
@@ -1097,10 +1094,8 @@ export class FleetPanel extends Container {
|
|
|
1097
1094
|
this.linkInput.onSubmit = (todoIdRaw: string) => {
|
|
1098
1095
|
void this.executeFork(run.agent, finalTask, todoIdRaw.trim() || undefined, run.runId);
|
|
1099
1096
|
};
|
|
1100
|
-
this.linkInput.onEscape = () => { void this.executeFork(run.agent, finalTask, undefined, run.runId); };
|
|
1101
1097
|
this.renderShell();
|
|
1102
1098
|
};
|
|
1103
|
-
this.taskInput.onEscape = () => this.cancelRun();
|
|
1104
1099
|
this.runMode = true;
|
|
1105
1100
|
this.renderShell();
|
|
1106
1101
|
}
|
|
@@ -1134,19 +1129,16 @@ export class FleetPanel extends Container {
|
|
|
1134
1129
|
const lcName = name.trim() || "default";
|
|
1135
1130
|
this.lcPhase = "cwd";
|
|
1136
1131
|
this.lcCwdInput = new Input();
|
|
1137
|
-
// SPEC-6-5: 3rd input step — the dispatch cwd.
|
|
1138
|
-
//
|
|
1132
|
+
// SPEC-6-5: 3rd input step — the dispatch cwd. Enter accepts the session cwd (blank)
|
|
1133
|
+
// or a typed path; Escape cancels the run (panel-level intercept — #63).
|
|
1139
1134
|
this.lcCwdInput.onSubmit = (cwd: string) => {
|
|
1140
1135
|
const picked = cwd.trim() || this.deps.parentCwd;
|
|
1141
1136
|
void this.executeLifecycleRun(task.trim(), lcName, picked);
|
|
1142
1137
|
};
|
|
1143
|
-
this.lcCwdInput.onEscape = () => { void this.executeLifecycleRun(task.trim(), lcName, this.deps.parentCwd); };
|
|
1144
1138
|
this.renderShell();
|
|
1145
1139
|
};
|
|
1146
|
-
this.lcNameInput.onEscape = () => { void this.executeLifecycleRun(task.trim(), "default", this.deps.parentCwd); };
|
|
1147
1140
|
this.renderShell();
|
|
1148
1141
|
};
|
|
1149
|
-
this.lcTaskInput.onEscape = () => this.cancelLifecycleRun();
|
|
1150
1142
|
this.lcRunMode = true;
|
|
1151
1143
|
this.renderShell();
|
|
1152
1144
|
}
|
package/src/runtime/run-log.ts
CHANGED
|
@@ -33,6 +33,12 @@ export interface RunEndedEvent {
|
|
|
33
33
|
costTotal?: number;
|
|
34
34
|
/** SPEC-6-1: latest context-token snapshot at run end. */
|
|
35
35
|
contextTokens?: number;
|
|
36
|
+
/** #59: the failure reason on failed runs (post-hoc diagnosability from the journal). */
|
|
37
|
+
error?: string;
|
|
38
|
+
/** #61: executed-tool count (the zero-work premature-return signal, post-hoc too). */
|
|
39
|
+
toolCallCount?: number;
|
|
40
|
+
/** #60: file paths the run mutated (post-hoc — was previously SpawnResult-only). */
|
|
41
|
+
filesTouched?: string[];
|
|
36
42
|
}
|
|
37
43
|
export type RunLogEvent = RunMetaEvent | MessageEvent | ToolEvent | RunEndedEvent;
|
|
38
44
|
|
package/src/tiers/builtin.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import type { Tier } from "./tier-registry.ts";
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Shipped default tiers (Q10). Overridable via global/project tiers.json.
|
|
5
|
+
*
|
|
6
|
+
* Provider-agnostic since #64: the `inherit` sentinel resolves to the parent/active
|
|
7
|
+
* session model, so zero-config fleets work on ANY provider. Users who want real
|
|
8
|
+
* multi-model cost routing override these by name with concrete `provider/id`
|
|
9
|
+
* chains (`inherit` may also appear mid-chain as a fallback). `contextFloor`
|
|
10
|
+
* guards concrete candidates; `costCap` is a per-run $ abort (configure where
|
|
11
|
+
* meaningful — it is a no-op on flat subscriptions).
|
|
12
|
+
*/
|
|
4
13
|
export const BUILTIN_TIERS: Tier[] = [
|
|
5
|
-
{ name: "economy", models: ["
|
|
6
|
-
{ name: "standard", models: ["
|
|
7
|
-
{ name: "frontier", models: ["
|
|
8
|
-
];
|
|
14
|
+
{ name: "economy", models: ["inherit"] },
|
|
15
|
+
{ name: "standard", models: ["inherit"] },
|
|
16
|
+
{ name: "frontier", models: ["inherit"], contextFloor: 200000 },
|
|
17
|
+
];
|
package/src/tiers/resolve.ts
CHANGED
|
@@ -34,6 +34,20 @@ export function resolveAgentModel(
|
|
|
34
34
|
if (!tier) return { error: `tier '${agent.tier}' not found; available: ${tiers.list().map((t) => t.name).join(", ")}` };
|
|
35
35
|
const candidates: string[] = [];
|
|
36
36
|
for (const m of tier.models) {
|
|
37
|
+
if (m.trim().toLowerCase() === "inherit") {
|
|
38
|
+
// #64: "inherit" = use the parent/active model — always eligible, no catalog or
|
|
39
|
+
// contextFloor check (the session model is presumed appropriate; the floor guards
|
|
40
|
+
// concrete candidates). Participates in the chain: eligible concrete models listed
|
|
41
|
+
// before it win; after them it acts as the provider-agnostic fallback.
|
|
42
|
+
// Sentinel is case-insensitive/trimmed; concrete model strings stay verbatim.
|
|
43
|
+
// Empty parentModel (dispatch before a session model exists) is NOT pushed — a
|
|
44
|
+
// pure-inherit tier then falls to the descriptive no-eligible-model error below.
|
|
45
|
+
if (parentModel.provider || parentModel.id) {
|
|
46
|
+
const parent = `${parentModel.provider}/${parentModel.id}`;
|
|
47
|
+
if (!candidates.includes(parent)) candidates.push(parent);
|
|
48
|
+
}
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
37
51
|
const { provider, id } = splitModel(m, parentModel.provider);
|
|
38
52
|
const model = modelRegistry.find(provider, id);
|
|
39
53
|
if (!model) continue; // not in catalog → skip
|
package/src/tools/subagent.ts
CHANGED
|
@@ -231,14 +231,43 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
231
231
|
});
|
|
232
232
|
retriedWithModel = fallback;
|
|
233
233
|
}
|
|
234
|
+
// #59: when the fallback retry ALSO fails, surface the PRIMARY's failure too — returning
|
|
235
|
+
// only the fallback's error masked why the primary (e.g. an explicit model string) failed
|
|
236
|
+
// at all, making provider diagnosis impossible from the controller's seat.
|
|
237
|
+
if (retriedWithModel && finalRes.status === "failed" && res.error && res.error !== finalRes.error) {
|
|
238
|
+
finalRes = {
|
|
239
|
+
...finalRes,
|
|
240
|
+
error: `primary '${res.model}' failed: ${res.error}; fallback '${retriedWithModel}' failed: ${finalRes.error ?? finalRes.status}`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
// #58: a retryable failure with NO fallback configured (neither per-dispatch nor global)
|
|
244
|
+
// means the auto-retry silently didn't fire — surface that, and how to enable it, exactly
|
|
245
|
+
// when it matters. Mutually exclusive with the #59 composition above (a retry implies a
|
|
246
|
+
// fallback was configured).
|
|
247
|
+
if (finalRes.status === "failed" && finalRes.retryable && !fallback) {
|
|
248
|
+
finalRes = {
|
|
249
|
+
...finalRes,
|
|
250
|
+
error: `${finalRes.error ?? finalRes.status}\n(no modelFallback configured — pass modelFallback or set ARMORY_FLEET_MODEL_FALLBACK to enable one-shot auto-retry)`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
234
253
|
const isError = finalRes.status === "failed" || finalRes.status === "aborted";
|
|
254
|
+
// #61: a run that "completed" without a single tool call is usually a premature return
|
|
255
|
+
// (the child narrated a plan and ended without acting) — flag it in-band so the controller
|
|
256
|
+
// verifies (git status/log) instead of trusting a terse planning statement as a completion.
|
|
257
|
+
const zeroToolRun = !isError && (finalRes.toolCallCount ?? 0) === 0;
|
|
258
|
+
const resultText = isError
|
|
259
|
+
? (finalRes.error ?? finalRes.status)
|
|
260
|
+
: zeroToolRun
|
|
261
|
+
? `[FLEET] zero-tool-call run — likely a premature return (#61); verify with git status/log before trusting this result.\n\n${finalRes.finalText}`
|
|
262
|
+
: finalRes.finalText;
|
|
235
263
|
return {
|
|
236
|
-
content: [{ type: "text" as const, text:
|
|
264
|
+
content: [{ type: "text" as const, text: resultText }],
|
|
237
265
|
details: {
|
|
238
266
|
runId: finalRes.runId, todoId: finalRes.todoId, agent: finalRes.agent, model: finalRes.model,
|
|
239
267
|
status: finalRes.status, durationMs: finalRes.durationMs, tokenTotal: finalRes.tokenTotal,
|
|
240
268
|
retriedWithModel,
|
|
241
269
|
filesTouched: finalRes.filesTouched, reachedSummary: finalRes.reachedSummary,
|
|
270
|
+
toolCallCount: finalRes.toolCallCount, // #61: zero = the premature-return signal
|
|
242
271
|
},
|
|
243
272
|
isError,
|
|
244
273
|
};
|