@foldrun/core 2.4.0 → 2.6.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.
Files changed (149) hide show
  1. package/dist/src/agent-activity.d.ts +5 -0
  2. package/dist/src/agent-activity.js +31 -0
  3. package/dist/src/agent-import.js +2 -2
  4. package/dist/src/agent-tools.js +2 -2
  5. package/dist/src/agents-md.d.ts +5 -1
  6. package/dist/src/agents-md.js +42 -18
  7. package/dist/src/alerts.d.ts +60 -0
  8. package/dist/src/alerts.js +161 -0
  9. package/dist/src/browser-pod.js +4 -0
  10. package/dist/src/capture.d.ts +23 -0
  11. package/dist/src/capture.js +80 -0
  12. package/dist/src/compare.d.ts +57 -0
  13. package/dist/src/compare.js +116 -0
  14. package/dist/src/completions.js +26 -3
  15. package/dist/src/deploy.d.ts +36 -1
  16. package/dist/src/deploy.js +237 -13
  17. package/dist/src/egress.d.ts +16 -1
  18. package/dist/src/evals.d.ts +27 -2
  19. package/dist/src/evals.js +62 -19
  20. package/dist/src/flow-lint.d.ts +14 -0
  21. package/dist/src/flow-lint.js +35 -0
  22. package/dist/src/flow-patterns.js +29 -14
  23. package/dist/src/frontmatter-fields.d.ts +27 -0
  24. package/dist/src/frontmatter-fields.js +60 -3
  25. package/dist/src/frontmatter-guard.d.ts +19 -0
  26. package/dist/src/frontmatter-guard.js +67 -0
  27. package/dist/src/frontmatter-schema.d.ts +2 -2
  28. package/dist/src/frontmatter-schema.js +33 -2
  29. package/dist/src/frontmatter-text.d.ts +31 -0
  30. package/dist/src/frontmatter-text.js +102 -0
  31. package/dist/src/history.d.ts +4 -0
  32. package/dist/src/history.js +15 -0
  33. package/dist/src/hooks.js +1 -1
  34. package/dist/src/insights.d.ts +30 -0
  35. package/dist/src/insights.js +152 -0
  36. package/dist/src/issues.d.ts +140 -0
  37. package/dist/src/issues.js +302 -0
  38. package/dist/src/judge-verdict.d.ts +19 -0
  39. package/dist/src/judge-verdict.js +54 -0
  40. package/dist/src/library.js +1 -1
  41. package/dist/src/limits.d.ts +8 -0
  42. package/dist/src/limits.js +36 -5
  43. package/dist/src/linkable.js +1 -1
  44. package/dist/src/matter.d.ts +42 -0
  45. package/dist/src/matter.js +132 -0
  46. package/dist/src/model-credential.d.ts +14 -0
  47. package/dist/src/model-credential.js +40 -0
  48. package/dist/src/network.d.ts +12 -1
  49. package/dist/src/network.js +35 -8
  50. package/dist/src/notify.d.ts +16 -0
  51. package/dist/src/notify.js +17 -7
  52. package/dist/src/okf.js +1 -1
  53. package/dist/src/online-checks.d.ts +76 -0
  54. package/dist/src/online-checks.js +432 -0
  55. package/dist/src/operator.d.ts +277 -0
  56. package/dist/src/operator.js +176 -2
  57. package/dist/src/otel-config.d.ts +18 -0
  58. package/dist/src/otel-config.js +69 -0
  59. package/dist/src/otel.d.ts +38 -0
  60. package/dist/src/otel.js +85 -0
  61. package/dist/src/outcome.d.ts +40 -0
  62. package/dist/src/outcome.js +77 -0
  63. package/dist/src/package.js +9 -2
  64. package/dist/src/platform.d.ts +1 -1
  65. package/dist/src/prompt-layers.d.ts +30 -0
  66. package/dist/src/prompt-layers.js +77 -0
  67. package/dist/src/provider-health.js +2 -2
  68. package/dist/src/providers.d.ts +5 -0
  69. package/dist/src/providers.js +2 -2
  70. package/dist/src/run-container.d.ts +25 -2
  71. package/dist/src/run-container.js +61 -6
  72. package/dist/src/runner.d.ts +53 -2
  73. package/dist/src/runner.js +471 -102
  74. package/dist/src/runs-filter.d.ts +48 -0
  75. package/dist/src/runs-filter.js +73 -0
  76. package/dist/src/scan.d.ts +30 -1
  77. package/dist/src/scan.js +145 -14
  78. package/dist/src/starter.js +27 -2
  79. package/dist/src/step-exec.d.ts +39 -3
  80. package/dist/src/step-exec.js +118 -15
  81. package/dist/src/store.d.ts +144 -3
  82. package/dist/src/store.js +150 -7
  83. package/dist/src/tokens.d.ts +38 -0
  84. package/dist/src/tokens.js +57 -0
  85. package/dist/src/tool-names.js +1 -1
  86. package/dist/src/tool-programs.js +1 -1
  87. package/dist/src/trace-tree.d.ts +40 -0
  88. package/dist/src/trace-tree.js +156 -0
  89. package/dist/src/translator.d.ts +8 -0
  90. package/dist/src/translator.js +7 -2
  91. package/dist/src/trigger-log.d.ts +1 -1
  92. package/dist/src/workspace-check.d.ts +18 -0
  93. package/dist/src/workspace-check.js +34 -0
  94. package/package.json +2 -2
  95. package/src/agent-activity.ts +32 -0
  96. package/src/agent-import.ts +2 -2
  97. package/src/agent-tools.ts +2 -2
  98. package/src/agents-md.ts +39 -14
  99. package/src/alerts.ts +190 -0
  100. package/src/browser-pod.ts +3 -0
  101. package/src/capture.ts +88 -0
  102. package/src/compare.ts +146 -0
  103. package/src/completions.ts +27 -3
  104. package/src/deploy.ts +238 -12
  105. package/src/egress.ts +12 -2
  106. package/src/evals.ts +64 -19
  107. package/src/flow-lint.ts +36 -0
  108. package/src/flow-patterns.ts +26 -13
  109. package/src/frontmatter-fields.ts +83 -3
  110. package/src/frontmatter-guard.ts +70 -0
  111. package/src/frontmatter-schema.ts +33 -4
  112. package/src/frontmatter-text.ts +109 -0
  113. package/src/history.ts +13 -0
  114. package/src/hooks.ts +1 -1
  115. package/src/insights.ts +174 -0
  116. package/src/issues.ts +396 -0
  117. package/src/judge-verdict.ts +52 -0
  118. package/src/library.ts +1 -1
  119. package/src/limits.ts +38 -5
  120. package/src/linkable.ts +1 -1
  121. package/src/matter.ts +145 -0
  122. package/src/model-credential.ts +45 -0
  123. package/src/network.ts +35 -7
  124. package/src/notify.ts +22 -7
  125. package/src/okf.ts +1 -1
  126. package/src/online-checks.ts +450 -0
  127. package/src/operator.ts +213 -3
  128. package/src/otel-config.ts +67 -0
  129. package/src/otel.ts +100 -0
  130. package/src/outcome.ts +90 -0
  131. package/src/package.ts +8 -2
  132. package/src/platform.ts +1 -1
  133. package/src/prompt-layers.ts +90 -0
  134. package/src/provider-health.ts +2 -2
  135. package/src/providers.ts +7 -2
  136. package/src/run-container.ts +77 -8
  137. package/src/runner.ts +477 -79
  138. package/src/runs-filter.ts +88 -0
  139. package/src/scan.ts +153 -14
  140. package/src/starter.ts +27 -2
  141. package/src/step-exec.ts +168 -17
  142. package/src/store.ts +268 -9
  143. package/src/tokens.ts +68 -0
  144. package/src/tool-names.ts +1 -1
  145. package/src/tool-programs.ts +1 -1
  146. package/src/trace-tree.ts +187 -0
  147. package/src/translator.ts +13 -2
  148. package/src/trigger-log.ts +1 -0
  149. package/src/workspace-check.ts +41 -0
package/src/step-exec.ts CHANGED
@@ -8,13 +8,14 @@
8
8
  // results. That is what lets the same function run somewhere the vault, the
9
9
  // library and the account do not exist.
10
10
 
11
+ import { judgeVerdict, JUDGE_VERDICT_INSTRUCTION } from "./judge-verdict.ts";
11
12
  import { runHook, runToolCallHooks, matchesTool, hookLine, resolveHooks, type HookDef, type HookRecord } from "./hooks.ts";
12
13
  import fs from "node:fs";
13
14
  import os from "node:os";
14
15
  import path from "node:path";
15
16
  import { query } from "@anthropic-ai/claude-agent-sdk";
16
17
  import type { HookInput, McpServerConfig, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
17
- import type { Effort } from "./store.ts";
18
+ import { CAPTURE_CAP, type Effort, type ModelCall } from "./store.ts";
18
19
  import { costOf, type TokenPrice } from "./catalog.ts";
19
20
  import type { TestEffect } from "./test-mode.ts";
20
21
  import type { OperatorEvent } from "./operator.ts";
@@ -23,7 +24,7 @@ import { checkPaths, checkBash, isFilesystemTool, linkWorkspace, workspaceLinkEn
23
24
  import { DELEGATE_TOOLS, subagentGuard, toAgentDefinitions, type SubagentSpec } from "./subagents.ts";
24
25
  import { hostSafeEnv } from "./host-env.ts";
25
26
  import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
26
- import { CallCounter, limitKeysFor, type Limits } from "./limits.ts";
27
+ import { CallCounter, callKeysFor, type Limits } from "./limits.ts";
27
28
  import { classifyCall, podAt, readPodEvents, type BrowserPodOutcome, type CallKind, type ClassifyContext } from "./browser-pod.ts";
28
29
 
29
30
  export interface ExecUsage {
@@ -86,6 +87,11 @@ export interface ExecOutcome {
86
87
  }
87
88
 
88
89
  export interface ExecOptions {
90
+ /** What the step reports beyond its events (FOL-54 step 4): `metadata`
91
+ * sends each model call (`turn`); `full` also sends each tool call's input
92
+ * and output (`io`). Unset sends neither — the consult, the probe and any
93
+ * caller that does not store them. */
94
+ capture?: "metadata" | "full";
89
95
  /** `hooks:` resolved host-side (hooks.ts): run at this step's own dispatch,
90
96
  * inside its sandbox. `hookBase` is what every hook event carries. */
91
97
  hooks?: HookDef[];
@@ -128,6 +134,11 @@ export interface ExecOptions {
128
134
  * `matches: re`, `file: path`, `judge: sentence`) — see checkVerify. */
129
135
  verify?: string;
130
136
  verifyEnv?: Record<string, string>;
137
+ /** The model `verify: judge:` grades with — the workspace's `judge:` from
138
+ * AGENTS.md, resolved; absent, the fast tier. */
139
+ judgeModel?: string;
140
+ /** And how long it thinks — `judge: {effort}`; absent, the SDK's default. */
141
+ judgeEffort?: Effort;
131
142
  /** `output: json` — the reply must carry one JSON value; extracting it is
132
143
  * part of finishing the step, and failing to is failing the step. */
133
144
  output?: "json";
@@ -195,7 +206,50 @@ export type QueryLike = AsyncIterable<unknown> & { interrupt(): Promise<unknown>
195
206
  export type QueryFn = (args: { prompt: string; options: Record<string, unknown> }) => QueryLike;
196
207
 
197
208
  /** The pairing fields on a tool event — see RunEvent in store.ts. */
198
- export type EventExtra = { call?: string; ms?: number; err?: boolean; bytes?: number; effect?: TestEffect; operator?: OperatorEvent; subagent?: string; check?: boolean; hook?: HookRecord };
209
+ export type EventExtra = {
210
+ call?: string;
211
+ ms?: number;
212
+ err?: boolean;
213
+ bytes?: number;
214
+ effect?: TestEffect;
215
+ operator?: OperatorEvent;
216
+ subagent?: string;
217
+ check?: boolean;
218
+ hook?: HookRecord;
219
+ /** A tool call's content, on its way to the runner (FOL-54 step 4): the
220
+ * runner masks it, stores it beside the run and keeps only `captured` on
221
+ * the event. Sent only under `capture: "full"`. */
222
+ io?: CallIo;
223
+ /** One model call, on its way to the runner's `step.calls` — not an event. */
224
+ turn?: ModelCall;
225
+ /** The model's first response of the step has begun streaming — the
226
+ * moment the prompt cache entry its request wrote becomes readable. Sent
227
+ * once; the runner's fan-out gate reads it. Not an event. */
228
+ responding?: true;
229
+ /** Set by the runner once it stored `io` (RunEvent.captured). */
230
+ captured?: boolean;
231
+ /** On a tool call: the model call (its message id) that asked for it. */
232
+ inCall?: string;
233
+ };
234
+
235
+ /** A tool call's input (on the call) or output (on its completion), capped. */
236
+ export interface CallIo {
237
+ input?: string;
238
+ output?: string;
239
+ /** The whole input's / output's length, in characters, before any cut. */
240
+ inputChars?: number;
241
+ outputChars?: number;
242
+ truncated?: boolean;
243
+ }
244
+
245
+ // The cap plus slack: the runner masks, then cuts to the cap (capture.ts), so
246
+ // a secret on the edge is masked whole.
247
+ const SEND_CAP = CAPTURE_CAP + 4096;
248
+
249
+ function capped(text: string): { text: string; chars: number; truncated: boolean } {
250
+ if (text.length <= SEND_CAP) return { text, chars: text.length, truncated: false };
251
+ return { text: text.slice(0, SEND_CAP), chars: text.length, truncated: true };
252
+ }
199
253
 
200
254
  /** The tag on every event a failed check writes — `verify:`, `output: json`,
201
255
  * a schema — headline and detail alike. The runner's no-retry rule for
@@ -391,6 +445,10 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
391
445
  ...(opts.effort ? { effort: opts.effort } : {}),
392
446
  systemPrompt: opts.systemPrompt,
393
447
  ...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}),
448
+ // The raw stream, only when model calls are recorded: an assistant
449
+ // message carries usage from the call's start (output a few tokens) and
450
+ // no stop reason; the call's message_delta has both (FOL-72).
451
+ ...(opts.capture ? { includePartialMessages: true } : {}),
394
452
  // Sub-agents: the SDK's own delegation. Every definition carries an
395
453
  // explicit tool list (an absent one inherits all of ours).
396
454
  ...(subagents ? { agents: toAgentDefinitions(subagents) } : {}),
@@ -474,7 +532,7 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
474
532
  // refused before it runs: the tool, and any paid API behind it,
475
533
  // never sees it.
476
534
  if (counter.active) {
477
- const refusal = counter.take(limitKeysFor(hookInput.tool_name, hookInput.tool_input as Record<string, unknown>, opts.toolOwners));
535
+ const refusal = counter.take(callKeysFor(hookInput.tool_name, hookInput.tool_input as Record<string, unknown>, opts.toolOwners));
478
536
  if (refusal) {
479
537
  emit("info", `${refusal.message} (${hookInput.tool_name} refused)`, via ? { subagent: via } : undefined);
480
538
  return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: refusal.message } };
@@ -489,7 +547,7 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
489
547
  // model differently from its delegates can tell them apart.
490
548
  const v = await runToolCallHooks(toolHooks, { tool: hookInput.tool_name, input, base: via ? { ...hookBase, subagent: via } : hookBase }, callHook);
491
549
  for (const r of v.records) record(r, via);
492
- const refundCall = () => { if (counter.active) counter.refund(limitKeysFor(hookInput.tool_name, hookInput.tool_input as Record<string, unknown>, opts.toolOwners)); };
550
+ const refundCall = () => { if (counter.active) counter.refund(callKeysFor(hookInput.tool_name, hookInput.tool_input as Record<string, unknown>, opts.toolOwners)); };
493
551
  if (v.decision === "deny") {
494
552
  refundCall();
495
553
  return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: v.reason! } };
@@ -558,7 +616,7 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
558
616
  // anything outside it is a denial with a reason the model can act on.
559
617
  const fromGrantedServer = opts.mcpNames.some((n) => toolName.startsWith(`mcp__${n}__`));
560
618
  // A call the hook counted and this refuses never ran: give it back.
561
- const refund = () => { if (counter.active) counter.refund(limitKeysFor(toolName, input, opts.toolOwners)); };
619
+ const refund = () => { if (counter.active) counter.refund(callKeysFor(toolName, input, opts.toolOwners)); };
562
620
  if (!opts.allowed.includes(toolName) && !fromGrantedServer) {
563
621
  refund();
564
622
  return {
@@ -630,6 +688,29 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
630
688
  // ceiling and on the bill.
631
689
  const turns = new Map<string, { u: Record<string, number | undefined>; price: TokenPrice | null }>();
632
690
  let anonymousTurns = 0;
691
+ // Each model call as a span (FOL-54 step 4), keyed like `turns`: a turn's
692
+ // blocks arrive as several messages under one id, so it is updated in place
693
+ // and sent again; the runner keeps the latest by id.
694
+ const spans = new Map<string, ModelCall>();
695
+ // From the raw stream: which call each agent is in (message_start), and each
696
+ // call's final output tokens and stop reason (message_delta).
697
+ const streaming = new Map<string, string>();
698
+ let saidResponding = false;
699
+ const finals = new Map<string, { output?: number; stop?: string }>();
700
+ const withFinal = (span: ModelCall): ModelCall => {
701
+ const f = finals.get(span.id);
702
+ return {
703
+ ...span,
704
+ ...(f?.output !== undefined ? { tokens: { ...span.tokens, output: f.output } } : {}),
705
+ // Without the stream (an older CLI), a call that used tools stopped to use them.
706
+ ...(f?.stop ? { stop: f.stop } : !span.stop && span.tools > 0 ? { stop: "tool_use" } : {}),
707
+ };
708
+ };
709
+ // When each agent's next request went out — the step's own, and each
710
+ // sub-agent's apart, so one's tool results do not restart another's clock.
711
+ const lastInputAt = new Map<string, number>();
712
+ const sentAt = (via: string | null | undefined) => lastInputAt.get(via ?? "") ?? loopStartedAt;
713
+ let loopStartedAt = Date.now();
633
714
  const specByName = new Map((subagents ?? []).map((s) => [s.name, s]));
634
715
  const priceOf = (message: unknown): TokenPrice | null => {
635
716
  const parent = (message as { parent_tool_use_id?: string | null }).parent_tool_use_id;
@@ -705,11 +786,62 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
705
786
 
706
787
  try {
707
788
  for await (const message of q as AsyncIterable<SDKMessage>) {
789
+ // The session is up: the first model call is timed from here, not from
790
+ // before the SDK and its MCP servers started.
791
+ if (message.type === "system" && (message as { subtype?: string }).subtype === "init") loopStartedAt = Date.now();
792
+ if (message.type === "stream_event") {
793
+ const ev = (message as { event?: { type?: string; message?: { id?: string }; usage?: { output_tokens?: number }; delta?: { stop_reason?: string | null } } }).event;
794
+ const agentKey = (message as { parent_tool_use_id?: string | null }).parent_tool_use_id ?? "";
795
+ if (ev?.type === "message_start" && ev.message?.id) {
796
+ streaming.set(agentKey, ev.message.id);
797
+ // The step's own first response has begun: its prompt is in the
798
+ // cache now, so siblings held back to read it may start.
799
+ if (!agentKey && !saidResponding) {
800
+ saidResponding = true;
801
+ emit("info", "", { responding: true });
802
+ }
803
+ }
804
+ if (ev?.type === "message_delta") {
805
+ const id = streaming.get(agentKey);
806
+ if (id) {
807
+ finals.set(id, {
808
+ ...(typeof ev.usage?.output_tokens === "number" ? { output: ev.usage.output_tokens } : {}),
809
+ ...(ev.delta?.stop_reason ? { stop: ev.delta.stop_reason } : {}),
810
+ });
811
+ const span = spans.get(id);
812
+ if (span) {
813
+ const final = withFinal(span);
814
+ spans.set(id, final);
815
+ emit("info", "", { turn: final });
816
+ }
817
+ }
818
+ }
819
+ continue;
820
+ }
708
821
  if (ended) break;
709
822
  if (message.type === "assistant") {
710
- const m = message.message as unknown as { id?: string; usage?: Record<string, number | undefined> };
823
+ const m = message.message as unknown as { id?: string; model?: string; stop_reason?: string | null; usage?: Record<string, number | undefined>; content?: { type?: string }[] };
711
824
  const u = m.usage;
712
- if (u) turns.set(m.id ?? `turn-${anonymousTurns++}`, { u, price: priceOf(message) });
825
+ const turnKey = m.id ?? `turn-${anonymousTurns++}`;
826
+ if (u) turns.set(turnKey, { u, price: priceOf(message) });
827
+ if (opts.capture && u) {
828
+ const t = usageFromSdk(u);
829
+ const prior = spans.get(turnKey);
830
+ const span: ModelCall = {
831
+ id: turnKey,
832
+ n: prior?.n ?? spans.size + 1,
833
+ at: prior?.at ?? new Date().toISOString(),
834
+ ms: prior?.ms ?? Date.now() - sentAt(subagentOf(message)),
835
+ ...(m.model ? { model: m.model } : prior?.model ? { model: prior.model } : {}),
836
+ ...(m.stop_reason ? { stop: m.stop_reason } : prior?.stop ? { stop: prior.stop } : {}),
837
+ tokens: { input: t.inputTokens, output: t.outputTokens, cacheRead: t.cacheReadTokens, cacheWrite: t.cacheWriteTokens },
838
+ tools: (prior?.tools ?? 0) + (m.content ?? []).filter((b) => b?.type === "tool_use").length,
839
+ ...(subagentOf(message) ? { subagent: subagentOf(message)! } : {}),
840
+ };
841
+ const final = withFinal(span);
842
+ spans.set(turnKey, final);
843
+ emit("info", "", { turn: final });
844
+ }
713
845
  if (ceiling && u) {
714
846
  spentUsd = turnsTotal();
715
847
  if (spentUsd >= ceiling) {
@@ -731,13 +863,21 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
731
863
  const input = block.input as { subagent_type?: unknown; description?: unknown; prompt?: unknown } | undefined;
732
864
  const to = String(input?.subagent_type ?? "subagent");
733
865
  delegations.set(block.id, to);
866
+ // That sub-agent's first model call is timed from its job going out.
867
+ lastInputAt.set(to, Date.now());
734
868
  // One line a person can read: who got the job, and what it was.
735
869
  const job = String(input?.description ?? input?.prompt ?? "").replace(/\s+/g, " ").trim().slice(0, 160);
736
870
  emit("info", `delegating to ${to}${job ? `: ${job}` : ""}`, { subagent: to });
737
871
  }
738
872
  openCalls.set(block.id, { name: block.name, at: Date.now(), ...(via ? { subagent: via } : {}) });
739
873
  if (pod) ledger.set(block.id, classifyCall(block.name, block.input as Record<string, unknown> | undefined, pod));
740
- emit("tool", block.name, { call: block.id, ...(via ? { subagent: via } : {}) });
874
+ const input = opts.capture === "full" ? capped(JSON.stringify(block.input ?? {})) : null;
875
+ emit("tool", block.name, {
876
+ call: block.id,
877
+ ...(opts.capture ? { inCall: turnKey } : {}),
878
+ ...(via ? { subagent: via } : {}),
879
+ ...(input ? { io: { input: input.text, inputChars: input.chars, ...(input.truncated ? { truncated: true } : {}) } } : {}),
880
+ });
741
881
  }
742
882
  }
743
883
  } else if (message.type === "user") {
@@ -750,6 +890,7 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
750
890
  const open = openCalls.get(block.tool_use_id);
751
891
  if (!open) continue;
752
892
  openCalls.delete(block.tool_use_id);
893
+ const output = opts.capture === "full" ? capped(toolResultText(block.content)) : null;
753
894
  emit("tool", open.name, {
754
895
  call: block.tool_use_id,
755
896
  ms: Date.now() - open.at,
@@ -758,6 +899,7 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
758
899
  bytes: toolResultText(block.content).length,
759
900
  ...(block.is_error ? { err: true } : {}),
760
901
  ...(open.subagent ? { subagent: open.subagent } : {}),
902
+ ...(output ? { io: { output: output.text, outputChars: output.chars, ...(output.truncated ? { truncated: true } : {}) } } : {}),
761
903
  });
762
904
  // Why it failed, on the trail. The span above carried only `err`,
763
905
  // so a refused call left the agent's own guess as the only account
@@ -769,6 +911,8 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
769
911
  }
770
912
  readPod(block.tool_use_id);
771
913
  }
914
+ // That agent's next model call is timed from here: the results went back.
915
+ lastInputAt.set(subagentOf(message) ?? "", Date.now());
772
916
  }
773
917
  } else if (message.type === "result") {
774
918
  status = message.subtype === "success" ? "completed" : "failed";
@@ -928,6 +1072,8 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
928
1072
  data,
929
1073
  modelEnv: opts.env,
930
1074
  signal: halt.signal,
1075
+ ...(opts.judgeModel ? { judgeModel: opts.judgeModel } : {}),
1076
+ ...(opts.judgeEffort ? { judgeEffort: opts.judgeEffort } : {}),
931
1077
  });
932
1078
  } finally {
933
1079
  for (const t of clocks) clearTimeout(t);
@@ -1032,6 +1178,9 @@ export async function checkVerify(
1032
1178
  /** The step's own model environment, for `judge:` — it grades on the
1033
1179
  * fast tier through the same credential the step rode. */
1034
1180
  modelEnv?: Record<string, string | undefined>;
1181
+ /** The judge's model and effort (the workspace's `judge:`). */
1182
+ judgeModel?: string;
1183
+ judgeEffort?: Effort;
1035
1184
  /** Ends a shell check or a judge mid-way: the step's clock ran out, or a
1036
1185
  * person asked the run to stop. */
1037
1186
  signal?: AbortSignal;
@@ -1095,9 +1244,9 @@ export async function checkVerify(
1095
1244
  // GOOD` on a reply that did (fix-desk, run-muwevg72-7mza): the joined
1096
1245
  // text opened with the narration before it.
1097
1246
  const reply = ctx.conclusion?.trim() ? ctx.conclusion : output;
1098
- const verdict = await judgeReply(value, reply, output, ctx.modelEnv ?? {}, ctx.signal);
1099
- const ok = /^\s*PASS\b/i.test(verdict);
1100
- return { ok, headline: ok ? "PASS" : "FAIL", detail: verdict.slice(0, 300) };
1247
+ const said = await judgeReply(value, reply, output, ctx.modelEnv ?? {}, ctx.signal, ctx.judgeModel, ctx.judgeEffort);
1248
+ const { passed: ok, detail } = judgeVerdict(said);
1249
+ return { ok, headline: ok ? "PASS" : "FAIL", detail };
1101
1250
  }
1102
1251
  }
1103
1252
  return { ok: false, headline: `unknown check "${kind}"`, detail: "" };
@@ -1105,8 +1254,8 @@ export async function checkVerify(
1105
1254
 
1106
1255
  /**
1107
1256
  * A toolless, fast-tier grading call: does the reply satisfy the sentence?
1108
- * Same shape as the eval judge, and answered with one word first so the
1109
- * verdict is a prefix test rather than a reading.
1257
+ * Same shape as the eval judge: reasoning first, then the verdict alone on
1258
+ * the last line (judge-verdict.ts says why).
1110
1259
  */
1111
1260
  export function judgePrompt(rubric: string, reply: string, work: string): string {
1112
1261
  const shown = reply.slice(0, 20_000);
@@ -1115,8 +1264,7 @@ export function judgePrompt(rubric: string, reply: string, work: string): string
1115
1264
  `never this:\n<work>\n${work.slice(-20_000)}\n</work>\n\n`
1116
1265
  : "";
1117
1266
  return (
1118
- `You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
1119
- `first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
1267
+ `You are grading a reply against one requirement. ${JUDGE_VERDICT_INSTRUCTION}\n\nRequirement: ${rubric}\n\n` +
1120
1268
  context +
1121
1269
  `<reply>\n${shown}\n</reply>`
1122
1270
  );
@@ -1128,6 +1276,8 @@ async function judgeReply(
1128
1276
  work: string,
1129
1277
  env: Record<string, string | undefined>,
1130
1278
  signal?: AbortSignal,
1279
+ model = "haiku",
1280
+ effort?: Effort,
1131
1281
  ): Promise<string> {
1132
1282
  const texts: string[] = [];
1133
1283
  const abortController = new AbortController();
@@ -1137,7 +1287,8 @@ async function judgeReply(
1137
1287
  const q = query({
1138
1288
  prompt: judgePrompt(rubric, reply, work),
1139
1289
  options: {
1140
- model: "haiku",
1290
+ model,
1291
+ ...(effort ? { effort } : {}),
1141
1292
  systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
1142
1293
  tools: [],
1143
1294
  settingSources: [],