@getpipher/armory-fleet 0.13.0 → 0.14.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 CHANGED
@@ -316,6 +316,16 @@ Every workflow run is **journaled** (`workflows/journal.ts`) and **resumable**.
316
316
  - **Panel Run-action:** a 3rd `cwd` input step (task → name → cwd), prefilled with the session cwd; Enter accepts, Escape cancels.
317
317
  - **Deferred:** bg/scheduled + worktree cwd-isolation (the `cwd` param is honored by foreground dispatches only for now) — tracked in #62.
318
318
 
319
+ ## Dogfood reliability (v0.14.0)
320
+
321
+ Four fixes from dogfooding the fleet on itself (issues #58–#61):
322
+
323
+ - **`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.
324
+ - **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.
325
+ - **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.
326
+ - **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.
327
+ - **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`.
328
+
319
329
  ## Roadmap
320
330
 
321
331
  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.13.0",
3
+ "version": "0.14.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 CCMessage { role?: string; content?: Array<{ type: string; text?: string }>; usage?: Record<string, unknown>; }
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
- export function mapClaudeEvent(line: string): ChildSessionEvent | null {
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 null; // malformed line — resilient
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 null;
28
+ return [];
29
+ }
22
30
  case "assistant": {
23
31
  const msg = ev.message;
24
- if (!msg) return null;
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
- return { type: "message_end", message: { role: msg.role ?? "assistant", content, usage } };
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 null; // echo of our own stdin write — filtered
49
+ return []; // echo of our own stdin write — filtered
36
50
  default:
37
- return null; // unknown — forward-compat, caller logs at debug
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 { mapClaudeEvent } from "./claude-events.ts";
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 = mapClaudeEvent(line);
28
- if (!ev) return;
29
- if (ev.type === "session_init" && ev.backendSessionId && !this.initCaptured) {
30
- this.initCaptured = true;
31
- this.resumeStore.set("claude", this.sessionKey, ev.backendSessionId);
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
- return spawn({ ...opts, model: fallback });
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
- if (toolName === "edit" || toolName === "write") {
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 (toolName === "bash") {
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 ?? "", (e as { args?: unknown }).args)) filesTouched.add(f);
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, (e as any).args, (e as any).result, (e as any).isError ?? false, turnIdx));
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";
@@ -204,7 +205,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
204
205
  // #39 tail: global default fallback model (env-driven for now; a settings.json field is a follow-up).
205
206
  // A retryable provider failure (stopReason "error") retries once on this model even without a
206
207
  // per-dispatch `modelFallback`. Per the AGENTS.md "Ollama primary + OpenRouter fallback" pattern.
207
- deps.defaultModelFallback = process.env.ARMORY_FLEET_MODEL_FALLBACK || undefined;
208
+ // #39 tail + #58: the global default fallback. Non-"auto" env values are used verbatim;
209
+ // "auto" is resolved per-session in session_start (it needs the session model to differ from).
210
+ const rawModelFallback = process.env.ARMORY_FLEET_MODEL_FALLBACK || undefined;
211
+ deps.defaultModelFallback = rawModelFallback === "auto" ? undefined : rawModelFallback;
208
212
  // SPEC-6-5: cross-cwd dispatch notify hook (wired per-session in session_start below).
209
213
  // Placeholder; the real wiring happens in session_start where ctx is in scope.
210
214
 
@@ -309,6 +313,15 @@ export default async function (pi: ExtensionAPI): Promise<void> {
309
313
  refreshLifecycles(ctx);
310
314
  const m = ctx.model;
311
315
  deps.parentModel = m ? { provider: m.provider, id: m.id } : { provider: "", id: "" };
316
+ // #58: ARMORY_FLEET_MODEL_FALLBACK=auto — pick a fallback from the configured+available
317
+ // snapshot that differs from the session model (different provider preferred). Unresolvable
318
+ // (single-model setup) → stay off + say why once per session.
319
+ if (rawModelFallback === "auto") {
320
+ deps.defaultModelFallback = resolveAutoFallback(modelRuntime.getAvailableSnapshot(), deps.parentModel);
321
+ if (!deps.defaultModelFallback) {
322
+ ctx.ui.notify("ARMORY_FLEET_MODEL_FALLBACK=auto, but no alternative configured model is available — auto-retry stays off", "warning");
323
+ }
324
+ }
312
325
  deps.parentCwd = ctx.cwd;
313
326
  deps.onNotify = (m, k) => ctx.ui.notify(m, k ?? "info");
314
327
  // SPEC-5a: build the per-session async runner + scheduler, start firing, scan for interrupted runs.
@@ -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
 
@@ -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: isError ? (finalRes.error ?? finalRes.status) : finalRes.finalText }],
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
  };