@inflexa-ai/harness 0.14.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -39,7 +39,7 @@ export type { AgentSession, RequestSession, RunSession, Identity, Scope, Credent
39
39
  export { makeLocalAuth } from "./auth/local-auth-context.js";
40
40
  export { createAnthropicProvider } from "./providers/anthropic.js";
41
41
  export type { AnthropicProviderDeps } from "./providers/anthropic.js";
42
- export { createConfiguredAiSdkProvider } from "./providers/ai-sdk.js";
42
+ export { createConfiguredAiSdkProvider, DEFAULT_MAX_OUTPUT_TOKENS } from "./providers/ai-sdk.js";
43
43
  export type { AiSdkProviderConfig, ConfiguredAiSdkProviderDeps } from "./providers/ai-sdk.js";
44
44
  export { createEmbeddingProvider } from "./providers/embedding.js";
45
45
  export type { EmbeddingProviderDeps } from "./providers/embedding.js";
package/dist/index.js CHANGED
@@ -48,7 +48,7 @@ export { createAnthropicProvider } from "./providers/anthropic.js";
48
48
  // union discriminated over the `anthropic` and `openai-compatible` kinds — with
49
49
  // `ConfiguredAiSdkProviderDeps` as its argument shape. `createAnthropicProvider`
50
50
  // above is a convenience over this union's `anthropic` arm.
51
- export { createConfiguredAiSdkProvider } from "./providers/ai-sdk.js";
51
+ export { createConfiguredAiSdkProvider, DEFAULT_MAX_OUTPUT_TOKENS } from "./providers/ai-sdk.js";
52
52
  export { createEmbeddingProvider } from "./providers/embedding.js";
53
53
  // Prompt caching. `PromptCachePolicy` is the harness's vendor-neutral cache
54
54
  // directive; a composition root sets it per run via `RunAgentOptions.promptCache`
@@ -60,6 +60,12 @@ export async function runAgent(agent, initial, session, opts) {
60
60
  });
61
61
  let iterations = 0;
62
62
  let truncationRecoveries = 0;
63
+ // Counted rather than listed: the finish record stays one bounded line for a run of
64
+ // any length, and "50 iterations, 49 tool calls, 47 of them errored" already separates
65
+ // a productive long run from a loop stuck retrying one failing call. The per-iteration
66
+ // `debug` records name the tools when that distinction is not enough.
67
+ let toolCallCount = 0;
68
+ let toolErrorCount = 0;
63
69
  // Resolved once, not per iteration: an identical options object across every
64
70
  // call is itself part of the cache contract — the request prefix has to be
65
71
  // byte-identical to be read back.
@@ -69,7 +75,7 @@ export async function runAgent(agent, initial, session, opts) {
69
75
  // what keeps the default level affordable for an agent that runs long; the
70
76
  // per-iteration detail lives at `debug`, where paying per iteration is the point.
71
77
  const logFinish = (level, reason, cappedOut) => {
72
- log[level]("run finished", { iterations, reason, cappedOut, truncationRecoveries, usage });
78
+ log[level]("run finished", { iterations, reason, cappedOut, truncationRecoveries, toolCalls: toolCallCount, toolErrors: toolErrorCount, usage });
73
79
  };
74
80
  // The user said no. A subsequent model call would only let the agent argue
75
81
  // with the decision, or spend a call acknowledging it; the denial tool result
@@ -82,6 +88,29 @@ export async function runAgent(agent, initial, session, opts) {
82
88
  logFinish("warn", "denied", false);
83
89
  return { messages, finish: { reason: "denied", cappedOut: false, truncationRecoveries } };
84
90
  };
91
+ /**
92
+ * Fold one dispatch round into the run's counters and the event sink, returning the
93
+ * tools whose results the model will read as errors.
94
+ *
95
+ * Shared by both dispatch paths so a truncated round and a normal one cannot drift
96
+ * in what they count. An error tool result is otherwise invisible to every sink —
97
+ * it is not a thrown failure, so nothing reports it and the loop simply feeds it
98
+ * back — and a run that ends badly is usually a run whose tool calls were failing.
99
+ */
100
+ const settleRound = async (calls, results) => {
101
+ const errored = [];
102
+ for (let idx = 0; idx < calls.length; idx++) {
103
+ const tu = calls[idx];
104
+ const isError = isErrorOutput(results[idx]);
105
+ toolCallCount++;
106
+ if (isError) {
107
+ toolErrorCount++;
108
+ errored.push(tu.toolName);
109
+ }
110
+ await emit({ type: "tool-finished", source, toolUseId: tu.toolCallId, name: tu.toolName, isError });
111
+ }
112
+ return errored;
113
+ };
85
114
  const stopOnResolved = async (i) => {
86
115
  await emit({ type: "iteration", source, index: i, final: true });
87
116
  recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
@@ -134,17 +163,16 @@ export async function runAgent(agent, initial, session, opts) {
134
163
  await emit({ type: "tool-started", source, toolUseId: tu.toolCallId, name: tu.toolName, input: tu.input });
135
164
  }
136
165
  const results = await dispatchTools(earlier, toolsById, toolCtx, isFatalLoopError, runStep, formatStepName.tool);
137
- for (let idx = 0; idx < earlier.length; idx++) {
138
- const tu = earlier[idx];
139
- await emit({
140
- type: "tool-finished",
141
- source,
142
- toolUseId: tu.toolCallId,
143
- name: tu.toolName,
144
- isError: isErrorOutput(results[idx]),
145
- });
146
- }
166
+ const errored = await settleRound(earlier, results);
147
167
  results.push(errorResult(trailing, TRUNCATED_TOOL_USE_ERROR));
168
+ // The trailing call was never dispatched, but it reaches the model as an error
169
+ // result like any other — so it counts, or `toolErrors` reports a cleaner run
170
+ // than the model actually read. It gets no `tool-finished` event: no
171
+ // `tool-started` was emitted for it either, and the pair must stay balanced.
172
+ toolCallCount++;
173
+ toolErrorCount++;
174
+ errored.push(trailing.toolName);
175
+ log.debug("tool results returned errors", { iteration: i, tools: errored });
148
176
  messages.push({ role: "tool", content: results });
149
177
  if (hasDenial(results))
150
178
  return stopOnDenial(i);
@@ -164,16 +192,9 @@ export async function runAgent(agent, initial, session, opts) {
164
192
  await emit({ type: "tool-started", source, toolUseId: tu.toolCallId, name: tu.toolName, input: tu.input });
165
193
  }
166
194
  const results = await dispatchTools(toolCalls, toolsById, toolCtx, isFatalLoopError, runStep, formatStepName.tool);
167
- for (let idx = 0; idx < toolCalls.length; idx++) {
168
- const tu = toolCalls[idx];
169
- await emit({
170
- type: "tool-finished",
171
- source,
172
- toolUseId: tu.toolCallId,
173
- name: tu.toolName,
174
- isError: isErrorOutput(results[idx]),
175
- });
176
- }
195
+ const errored = await settleRound(toolCalls, results);
196
+ if (errored.length > 0)
197
+ log.debug("tool results returned errors", { iteration: i, tools: errored });
177
198
  messages.push({ role: "tool", content: results });
178
199
  if (hasDenial(results))
179
200
  return stopOnDenial(i);
@@ -22,10 +22,30 @@
22
22
  */
23
23
  import type { AgentSession } from "../auth/types.js";
24
24
  import type { Tool } from "../tools/define-tool.js";
25
- import { type RunAgentOptions, type RunAgentResult } from "./run-agent.js";
25
+ import { type AgentFinish, type RunAgentOptions, type RunAgentResult } from "./run-agent.js";
26
26
  import type { AgentDefinition, LoopMessage } from "./types.js";
27
27
  /** Default salvage budget: one submit plus a validation-fix retry or two. */
28
28
  export declare const DEFAULT_SALVAGE_ITERATIONS = 3;
29
+ /**
30
+ * What a salvage continuation was and how it went — present only when one ran.
31
+ *
32
+ * The two finishes answer different questions and a diagnostician needs both: a
33
+ * first run that ended `max_iterations` spent its whole budget failing to submit,
34
+ * while one that ended `stop` gave up on prose after a single turn. Those call for
35
+ * opposite responses, and the returned `RunAgentResult` carries only the second
36
+ * run's finish, which is the same for either.
37
+ */
38
+ export interface SalvageRecord {
39
+ /** How the FIRST run ended — the reason a salvage was needed at all. */
40
+ readonly firstFinish: AgentFinish;
41
+ /** How the salvage continuation itself ended. */
42
+ readonly finish: AgentFinish;
43
+ }
44
+ /** A `runToTerminal` outcome: the driving run's result, plus the salvage account. */
45
+ export interface RunToTerminalResult extends RunAgentResult {
46
+ /** Null when the first run reached its terminal tool and no salvage was needed. */
47
+ readonly salvage: SalvageRecord | null;
48
+ }
29
49
  /** Describes how to salvage a run that never reached its terminal tool. */
30
50
  export interface TerminalSalvage {
31
51
  /** Terminal tools offered on the salvage turn (submit / blocker / …). Must be
@@ -41,4 +61,4 @@ export interface TerminalSalvage {
41
61
  * Returns the salvage run's result when a salvage occurred (its message array
42
62
  * already includes the first run's), otherwise the first run's result.
43
63
  */
44
- export declare function runToTerminal(agent: AgentDefinition, initial: readonly LoopMessage[], session: AgentSession, opts: RunAgentOptions, salvage: TerminalSalvage): Promise<RunAgentResult>;
64
+ export declare function runToTerminal(agent: AgentDefinition, initial: readonly LoopMessage[], session: AgentSession, opts: RunAgentOptions, salvage: TerminalSalvage): Promise<RunToTerminalResult>;
@@ -38,21 +38,30 @@ function salvageStepNames(base) {
38
38
  export async function runToTerminal(agent, initial, session, opts, salvage) {
39
39
  const first = await runAgent(agent, initial, session, opts);
40
40
  if (opts.resolved?.() || opts.signal.aborted)
41
- return first;
41
+ return { ...first, salvage: null };
42
+ const salvageBudget = salvage.maxIterations ?? DEFAULT_SALVAGE_ITERATIONS;
42
43
  // Reported here rather than in `runAgent` because the loop cannot know it is being
43
44
  // salvaged: it sees an ordinary run with a small budget and a restricted tool set.
44
45
  // Only this wrapper holds the fact that a first attempt ended without its outcome.
45
- (opts.logger ?? createNoopLogger())
46
- .named("loop")
47
- .warn("salvaging a run that never reached its terminal tool", { agentId: agent.id, callPath: session.provenance.callPath });
46
+ // The first run's finish rides along because it is the whole diagnosis of WHY a
47
+ // salvage was needed, and it is the field the second run's result overwrites.
48
+ (opts.logger ?? createNoopLogger()).named("loop").warn("salvaging a run that never reached its terminal tool", {
49
+ agentId: agent.id,
50
+ callPath: session.provenance.callPath,
51
+ firstFinishReason: first.finish.reason,
52
+ firstCappedOut: first.finish.cappedOut,
53
+ salvageTools: salvage.tools.map((t) => t.id),
54
+ salvageMaxIterations: salvageBudget,
55
+ });
48
56
  const salvageAgent = {
49
57
  ...agent,
50
58
  tools: [...salvage.tools],
51
- maxIterations: salvage.maxIterations ?? DEFAULT_SALVAGE_ITERATIONS,
59
+ maxIterations: salvageBudget,
52
60
  };
53
61
  const salvageOpts = {
54
62
  ...opts,
55
63
  formatStepName: salvageStepNames(opts.formatStepName ?? DEFAULT_STEP_NAME_FORMATTER),
56
64
  };
57
- return runAgent(salvageAgent, [...first.messages, { role: "user", content: salvage.nudge }], session, salvageOpts);
65
+ const salvaged = await runAgent(salvageAgent, [...first.messages, { role: "user", content: salvage.nudge }], session, salvageOpts);
66
+ return { ...salvaged, salvage: { firstFinish: first.finish, finish: salvaged.finish } };
58
67
  }
@@ -13,11 +13,25 @@ export declare const RETRY_MAX_RETRIES = 10;
13
13
  export declare const RETRY_INITIAL_DELAY_MS = 2000;
14
14
  export declare const RETRY_BACKOFF_FACTOR = 2;
15
15
  export declare const RETRY_MAX_DELAY_MS = 30000;
16
+ /**
17
+ * Output-token ceiling requested when a config names none — a floor against a
18
+ * provider default, not a target. `@ai-sdk/anthropic` resolves an unset value
19
+ * from a capability table that lags Anthropic's releases and falls back to 4096
20
+ * for ids it does not know, so the newest models silently truncate mid
21
+ * tool-call. The SDK clamps this down per known model (opus-4-x to 32k,
22
+ * claude-3-haiku to 4096), so it cannot over-request one it recognizes.
23
+ *
24
+ * Lower it per-config for servers that validate `prompt + max_tokens <= context`
25
+ * (vLLM and similar) against a small-context model.
26
+ */
27
+ export declare const DEFAULT_MAX_OUTPUT_TOKENS = 64000;
16
28
  export interface AiSdkProviderDeps {
17
29
  readonly model: LanguageModel;
18
30
  readonly resolveBilling: ResolveBilling;
19
31
  readonly capabilities?: Partial<ProviderCapabilities>;
20
32
  readonly logger?: Logger;
33
+ /** Output-token ceiling per request. Defaults to {@link DEFAULT_MAX_OUTPUT_TOKENS}. */
34
+ readonly maxOutputTokens?: number;
21
35
  }
22
36
  /**
23
37
  * Connection + model configuration for one `ChatProvider`, discriminated by
@@ -36,6 +50,8 @@ export type AiSdkProviderConfig = {
36
50
  readonly model: string;
37
51
  readonly fetch?: FetchLike;
38
52
  readonly capabilities?: Partial<ProviderCapabilities>;
53
+ /** Output-token ceiling per request. Defaults to {@link DEFAULT_MAX_OUTPUT_TOKENS}. */
54
+ readonly maxOutputTokens?: number;
39
55
  } | {
40
56
  readonly kind: "openai-compatible";
41
57
  readonly name: string;
@@ -44,6 +60,8 @@ export type AiSdkProviderConfig = {
44
60
  readonly model: string;
45
61
  readonly fetch?: FetchLike;
46
62
  readonly capabilities?: Partial<ProviderCapabilities>;
63
+ /** Output-token ceiling per request. Defaults to {@link DEFAULT_MAX_OUTPUT_TOKENS}. */
64
+ readonly maxOutputTokens?: number;
47
65
  };
48
66
  export interface ConfiguredAiSdkProviderDeps {
49
67
  readonly config: AiSdkProviderConfig;
@@ -18,6 +18,38 @@ export const RETRY_MAX_RETRIES = 10;
18
18
  export const RETRY_INITIAL_DELAY_MS = 2_000;
19
19
  export const RETRY_BACKOFF_FACTOR = 2;
20
20
  export const RETRY_MAX_DELAY_MS = 30_000;
21
+ /**
22
+ * Output-token ceiling requested when a config names none — a floor against a
23
+ * provider default, not a target. `@ai-sdk/anthropic` resolves an unset value
24
+ * from a capability table that lags Anthropic's releases and falls back to 4096
25
+ * for ids it does not know, so the newest models silently truncate mid
26
+ * tool-call. The SDK clamps this down per known model (opus-4-x to 32k,
27
+ * claude-3-haiku to 4096), so it cannot over-request one it recognizes.
28
+ *
29
+ * Lower it per-config for servers that validate `prompt + max_tokens <= context`
30
+ * (vLLM and similar) against a small-context model.
31
+ */
32
+ export const DEFAULT_MAX_OUTPUT_TOKENS = 64_000;
33
+ /**
34
+ * Point the AI SDK's warning logger at the harness `Logger`.
35
+ *
36
+ * The SDK writes warnings itself — `process.emitWarning`, else `console.warn` —
37
+ * and offers only this process-global hook to redirect them. Left alone it
38
+ * bypasses the Logger seam entirely and lands on the stdout a TUI host owns;
39
+ * `maxOutputTokens` makes that routine, since the SDK warns on every request
40
+ * whose ceiling it clamps. Installed once: the handler is global, and every
41
+ * provider over a boot shares one sink.
42
+ */
43
+ function routeSdkWarningsTo(logger) {
44
+ const g = globalThis;
45
+ if (g.AI_SDK_LOG_WARNINGS !== undefined)
46
+ return;
47
+ g.AI_SDK_LOG_WARNINGS = (options) => {
48
+ for (const warning of options.warnings) {
49
+ logger.warn("ai sdk warning", { provider: options.provider, model: options.model, warning });
50
+ }
51
+ };
52
+ }
21
53
  function workloadOf(session) {
22
54
  return `${session.scope.kind}:${scopeWorkloadId(session.scope)}`;
23
55
  }
@@ -246,6 +278,8 @@ function sanitizeMessages(messages) {
246
278
  export function createAiSdkProvider(deps) {
247
279
  const capabilities = { toolCalling: deps.capabilities?.toolCalling ?? true };
248
280
  const logger = (deps.logger ?? createNoopLogger()).named("providers.ai-sdk");
281
+ const maxOutputTokens = deps.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
282
+ routeSdkWarningsTo(logger);
249
283
  function chat(req, session, signal) {
250
284
  const run = async () => {
251
285
  const retry = createRetry(signal, logger);
@@ -260,6 +294,7 @@ export function createAiSdkProvider(deps) {
260
294
  messages: sanitizeMessages(req.messages),
261
295
  tools: req.tools,
262
296
  toolChoice: req.toolChoice ?? "auto",
297
+ maxOutputTokens,
263
298
  stopWhen: [],
264
299
  // The harness envelope owns retries; leaving the SDK default in
265
300
  // place would multiply attempts (10 outer × 3 inner).
@@ -298,6 +333,7 @@ export function createAiSdkProvider(deps) {
298
333
  messages: sanitizeMessages(req.messages),
299
334
  tools: req.tools,
300
335
  toolChoice: req.toolChoice ?? "auto",
336
+ maxOutputTokens,
301
337
  stopWhen: [],
302
338
  maxRetries: 0,
303
339
  headers,
@@ -372,6 +408,7 @@ export function createConfiguredAiSdkProvider(deps) {
372
408
  resolveBilling: deps.resolveBilling,
373
409
  capabilities: config.capabilities,
374
410
  logger: deps.logger,
411
+ ...(config.maxOutputTokens !== undefined ? { maxOutputTokens: config.maxOutputTokens } : {}),
375
412
  });
376
413
  }
377
414
  const provider = createOpenAICompatible({
@@ -385,5 +422,6 @@ export function createConfiguredAiSdkProvider(deps) {
385
422
  resolveBilling: deps.resolveBilling,
386
423
  capabilities: config.capabilities,
387
424
  logger: deps.logger,
425
+ ...(config.maxOutputTokens !== undefined ? { maxOutputTokens: config.maxOutputTokens } : {}),
388
426
  });
389
427
  }
@@ -14,6 +14,8 @@ export interface AnthropicProviderDeps {
14
14
  readonly resolveBilling: ResolveBilling;
15
15
  readonly fetch?: FetchLike;
16
16
  readonly logger?: Logger;
17
+ /** Output-token ceiling per request. Defaults to `DEFAULT_MAX_OUTPUT_TOKENS`. */
18
+ readonly maxOutputTokens?: number;
17
19
  }
18
20
  /**
19
21
  * Convenience over the `anthropic` arm of the public `AiSdkProviderConfig`
@@ -22,6 +22,7 @@ export function createAnthropicProvider(deps) {
22
22
  model: deps.model,
23
23
  fetch: deps.fetch,
24
24
  capabilities: { toolCalling: true },
25
+ ...(deps.maxOutputTokens !== undefined ? { maxOutputTokens: deps.maxOutputTokens } : {}),
25
26
  },
26
27
  });
27
28
  }
@@ -27,7 +27,7 @@ import { ok } from "neverthrow";
27
27
  import { z } from "zod";
28
28
  import { formatAgentCatalog } from "../../agents/sandbox-catalog.js";
29
29
  import { composeSystemPrompt } from "../../agents/system-prompt.js";
30
- import { runToTerminal } from "../../loop/run-to-terminal.js";
30
+ import { DEFAULT_SALVAGE_ITERATIONS, runToTerminal } from "../../loop/run-to-terminal.js";
31
31
  import { passthroughStep } from "../../loop/run-step.js";
32
32
  import { forSubAgent, scopeResource } from "../../auth/types.js";
33
33
  import { defineTool } from "../define-tool.js";
@@ -55,6 +55,70 @@ const PLANNER_AGENT_ID = "planner";
55
55
  const PLANNER_MAX_ITERATIONS = 13;
56
56
  /** Wall-clock guard for a single plan-generation invocation. */
57
57
  const PLAN_TIMEOUT_MS = 600_000;
58
+ // ── Diagnostic bounds ───────────────────────────────────────────────
59
+ //
60
+ // This tool runs on `passthroughStep`: it writes no ledger row and owns no
61
+ // durable stream, so its log records are the ONLY account of an invocation that
62
+ // survives the turn. That makes them worth spending detail on — and makes every
63
+ // one of them a place an unbounded model-authored string could reach a log file.
64
+ // Each bound below is what keeps a record readable and a sink affordable when the
65
+ // planner is behaving at its worst, which is exactly when the record gets read.
66
+ /** Issues kept per rejection record. A plan bounces on a handful of distinct faults; the tail repeats. */
67
+ const MAX_LOGGED_ISSUES = 8;
68
+ /** Per-issue message budget — enough to identify the fault, not to reproduce the plan. */
69
+ const MAX_LOGGED_ISSUE_CHARS = 240;
70
+ /** Rejections kept on the finish record. Enough to show whether the planner converged or thrashed. */
71
+ const MAX_LOGGED_REJECTIONS = 6;
72
+ /** Excerpt of the planner's last words — the one artifact that explains a run ending on prose. */
73
+ const MAX_LOGGED_PROSE_CHARS = 800;
74
+ /** Tool-call trace length. The planner's whole budget is ~16 turns, so this rarely truncates. */
75
+ const MAX_LOGGED_TOOL_CALLS = 48;
76
+ /** Trim to `max`, marking the cut so a truncated value never reads as a complete one. */
77
+ function bounded(value, max) {
78
+ return value.length <= max ? value : `${value.slice(0, max)}…[+${value.length - max} chars]`;
79
+ }
80
+ /**
81
+ * Record key under which free-form MODEL-authored text rides — never a structural
82
+ * field, only prose a language model wrote.
83
+ *
84
+ * The segregation exists because these two things need different handling and are
85
+ * otherwise indistinguishable in a flat record. A planner's own words routinely
86
+ * quote the analysis: "atopic dermatitis skin biopsies; sample S7 is a QC outlier"
87
+ * is a real example. That is the user's data. It belongs in a local diagnostic
88
+ * file, where the user already holds it — and an embedder shipping records off the
89
+ * machine needs one rule to keep it there, not a list of field names that goes
90
+ * stale the moment a new field is added.
91
+ *
92
+ * The harness cannot enforce that rule: it does not own the sink. It owns the
93
+ * declaration, so a host has something to act on. The cli drops this key at its
94
+ * OTLP export boundary (`cli/src/lib/otel.ts`) while keeping it in the log file.
95
+ *
96
+ * Redaction at the logger root was the alternative and is wrong here: it applies
97
+ * to every stream, so it would blank the field in the local file too — deleting
98
+ * the one artifact that explains a planner which stopped on prose.
99
+ */
100
+ const MODEL_AUTHORED_KEY = "modelAuthored";
101
+ /** Nest model-authored prose under {@link MODEL_AUTHORED_KEY}, or contribute nothing when there is none. */
102
+ function modelAuthored(fields) {
103
+ const present = Object.entries(fields).filter((entry) => entry[1] !== undefined && entry[1].length > 0);
104
+ return present.length === 0 ? {} : { [MODEL_AUTHORED_KEY]: Object.fromEntries(present) };
105
+ }
106
+ function toRejectionRecord(attempt, issues) {
107
+ const kept = issues.slice(0, MAX_LOGGED_ISSUES);
108
+ return {
109
+ attempt,
110
+ issueCount: issues.length,
111
+ codes: {
112
+ schema: issues.filter((i) => i.code === "schema").length,
113
+ semantic: issues.filter((i) => i.code === "semantic").length,
114
+ },
115
+ paths: kept.map((i) => i.path),
116
+ messages: kept.map((i) => bounded(i.message, MAX_LOGGED_ISSUE_CHARS)),
117
+ };
118
+ }
119
+ function createPlannerTrace() {
120
+ return { submitAttempts: 0, rejectedAttempts: 0, rejections: [], duplicateTerminalCalls: 0 };
121
+ }
58
122
  // ── Prompt / catalog ────────────────────────────────────────────────
59
123
  /** The planner system prompt is deterministic per policy — build it once per
60
124
  * factory (the policy is a construction-time dep, fixed for the process). */
@@ -184,8 +248,13 @@ function renderDataContext(grounding) {
184
248
  /**
185
249
  * Persist a validated plan. Returns the planId, or an outcome-ready error
186
250
  * with a sanitized message — DB-shape errors never leak to the planner.
251
+ *
252
+ * The sanitization is for the model, not for the operator: "plan could not be
253
+ * saved, please try again" is all the planner may see, and it is useless to
254
+ * whoever has to fix the cause. The record here is the only place the real
255
+ * failure survives, so it is written before the message is flattened.
187
256
  */
188
- async function persistPlan(plan, ctx, pool) {
257
+ async function persistPlan(plan, ctx, pool, logger) {
189
258
  try {
190
259
  const planId = unwrapOrThrow(await insertPlan(pool, {
191
260
  analysisId: ctx.analysisId,
@@ -197,6 +266,12 @@ async function persistPlan(plan, ctx, pool) {
197
266
  catch (err) {
198
267
  const raw = err instanceof Error ? err.message : String(err);
199
268
  const isParentValidation = /^parent plan .* (?:not found|belongs to a different analysis)$/i.test(raw);
269
+ logger.error("plan persistence failed", {
270
+ parentPlanId: ctx.parentPlanId,
271
+ stepCount: plan.steps.length,
272
+ classified: isParentValidation ? "invalid_parent_plan" : "write_failed",
273
+ ...logger.errorFields(err),
274
+ });
200
275
  return {
201
276
  ok: false,
202
277
  message: isParentValidation ? "parentPlanId is not a valid plan for this analysis" : "plan could not be saved, please try again",
@@ -251,7 +326,7 @@ function fullyValidate(candidate, resourcePolicy) {
251
326
  * shared `holder` so the outer `execute` reads the terminal outcome after the
252
327
  * loop finishes.
253
328
  */
254
- function buildInnerTools(holder, persistCtx, pool, resourcePolicy, logger) {
329
+ function buildInnerTools(holder, trace, persistCtx, pool, resourcePolicy, logger) {
255
330
  const submitPlanTool = defineTool({
256
331
  id: "submit_plan",
257
332
  description: "Submit the final plan for persistence. Re-validates the plan; on " +
@@ -271,6 +346,11 @@ function buildInnerTools(holder, persistCtx, pool, resourcePolicy, logger) {
271
346
  }),
272
347
  execute: async (input) => {
273
348
  if (holder.outcome !== null) {
349
+ trace.duplicateTerminalCalls++;
350
+ logger.warn("submit_plan called after a terminal outcome was recorded", {
351
+ recordedOutcome: holder.outcome.kind,
352
+ duplicateTerminalCalls: trace.duplicateTerminalCalls,
353
+ });
274
354
  return ok({
275
355
  accepted: false,
276
356
  issues: [
@@ -282,12 +362,21 @@ function buildInnerTools(holder, persistCtx, pool, resourcePolicy, logger) {
282
362
  ],
283
363
  });
284
364
  }
365
+ const attempt = ++trace.submitAttempts;
285
366
  const result = fullyValidate(input.plan, resourcePolicy);
286
367
  if (!result.valid) {
287
- logger.debug("submit_plan rejected a plan", { issueCount: result.issues.length, issues: result.issues });
368
+ trace.rejectedAttempts++;
369
+ const rejection = toRejectionRecord(attempt, result.issues);
370
+ if (trace.rejections.length < MAX_LOGGED_REJECTIONS)
371
+ trace.rejections.push(rejection);
372
+ // `warn`, not `debug`: a rejection is non-terminal by design, so the model
373
+ // reads the issues and the invocation carries on — nothing else reports that
374
+ // an attempt was spent. A run that exhausts its budget on rejections ends
375
+ // looking identical to one that never tried, and this is the difference.
376
+ logger.warn("submit_plan rejected a plan", { ...rejection });
288
377
  return ok({ accepted: false, issues: result.issues });
289
378
  }
290
- const persisted = await persistPlan(result.plan, persistCtx, pool);
379
+ const persisted = await persistPlan(result.plan, persistCtx, pool, logger);
291
380
  if (!persisted.ok) {
292
381
  holder.outcome = { kind: "persist_error", message: persisted.message };
293
382
  return ok({
@@ -307,6 +396,14 @@ function buildInnerTools(holder, persistCtx, pool, resourcePolicy, logger) {
307
396
  planId: persisted.planId,
308
397
  plan: result.plan,
309
398
  };
399
+ // The attempt count is the cost of this plan, and it is only knowable here.
400
+ // A plan accepted on attempt 5 is a success that nearly was not one.
401
+ logger.info("submit_plan accepted a plan", {
402
+ planId: persisted.planId,
403
+ attempt,
404
+ stepCount: result.plan.steps.length,
405
+ agents: [...new Set(result.plan.steps.map((s) => s.agent))],
406
+ });
310
407
  return ok({ accepted: true, planId: persisted.planId });
311
408
  },
312
409
  });
@@ -320,20 +417,47 @@ function buildInnerTools(holder, persistCtx, pool, resourcePolicy, logger) {
320
417
  questionContext: z.string().optional(),
321
418
  }),
322
419
  execute: async (input) => {
323
- if (holder.outcome === null) {
324
- holder.outcome = {
325
- kind: "clarification",
326
- question: input.question,
327
- questionContext: input.questionContext,
328
- };
420
+ if (holder.outcome !== null) {
421
+ trace.duplicateTerminalCalls++;
422
+ logger.warn("request_clarification called after a terminal outcome was recorded", {
423
+ recordedOutcome: holder.outcome.kind,
424
+ duplicateTerminalCalls: trace.duplicateTerminalCalls,
425
+ });
426
+ return ok({ recorded: true });
329
427
  }
428
+ holder.outcome = {
429
+ kind: "clarification",
430
+ question: input.question,
431
+ questionContext: input.questionContext,
432
+ };
433
+ // The question is the answer to "why did planning stop?" — and it reaches the
434
+ // user through the conversation agent, which may paraphrase it into something
435
+ // that no longer identifies the missing fact.
436
+ logger.info("planner requested clarification", {
437
+ submitAttempts: trace.submitAttempts,
438
+ ...modelAuthored({ clarificationQuestion: bounded(input.question, MAX_LOGGED_PROSE_CHARS) }),
439
+ });
330
440
  return ok({ recorded: true });
331
441
  },
332
442
  });
333
443
  const reportBlockerTool = createReportBlockerToolFor({
334
444
  record: (outcome) => {
335
- if (holder.outcome === null)
336
- holder.outcome = outcome;
445
+ if (holder.outcome !== null) {
446
+ trace.duplicateTerminalCalls++;
447
+ logger.warn("report_blocker called after a terminal outcome was recorded", {
448
+ recordedOutcome: holder.outcome.kind,
449
+ duplicateTerminalCalls: trace.duplicateTerminalCalls,
450
+ });
451
+ return;
452
+ }
453
+ holder.outcome = outcome;
454
+ // A blocker after several rejected submits is a planner giving up on a plan it
455
+ // could not make valid — a validation problem wearing a blocker's clothes. The
456
+ // attempt count is what tells those apart, and only this record carries both.
457
+ logger.warn("planner reported a blocker", {
458
+ submitAttempts: trace.submitAttempts,
459
+ ...modelAuthored({ blockerReason: bounded(outcome.reason, MAX_LOGGED_PROSE_CHARS) }),
460
+ });
337
461
  },
338
462
  blockedWhen: "Ends plan generation with no plan saved. Use it when no valid plan can " +
339
463
  "be produced for this data and research question (out of scope, data " +
@@ -343,15 +467,96 @@ function buildInnerTools(holder, persistCtx, pool, resourcePolicy, logger) {
343
467
  const terminal = [submitPlanTool, requestClarificationTool, reportBlockerTool];
344
468
  return { terminal };
345
469
  }
346
- function inventoryContent(label, result) {
347
- if (result.isErr())
470
+ /**
471
+ * Render one environment inventory into the seed, reporting a lookup that failed.
472
+ *
473
+ * A failed lookup is written INTO the prompt as prose the planner then plans
474
+ * around — a silent degradation of its grounding that no caller is told about and
475
+ * no outcome distinguishes. The record is what makes "the planner produced a plan
476
+ * naming packages this install does not have" traceable to its cause.
477
+ */
478
+ function inventoryContent(label, result, logger) {
479
+ if (result.isErr()) {
480
+ logger.warn("planner grounding inventory lookup failed", { inventory: label, error: result.error.error });
348
481
  return `## ${label}\n\nInventory lookup failed: ${result.error.error}`;
482
+ }
349
483
  const value = result.value;
350
484
  if (typeof value === "object" && value !== null && "content" in value && typeof value.content === "string") {
351
485
  return `## ${label}\n\n${value.content}`;
352
486
  }
353
487
  return `## ${label}\n\n${JSON.stringify(value)}`;
354
488
  }
489
+ // ── Transcript reading (diagnostics only) ───────────────────────────
490
+ /**
491
+ * The planner's own account of the run, read off the message array `runToTerminal`
492
+ * returns. Nothing else can produce it: the transcript is ephemeral — a sub-agent
493
+ * loop on `passthroughStep` persists no messages anywhere — so it is read here or
494
+ * it is lost with the turn.
495
+ *
496
+ * `toolCalls` in order is what shows the SHAPE of a failure at a glance:
497
+ * `[submit_plan × 13]` is a planner thrashing on validation, `[]` is a planner
498
+ * that never called anything, and a trailing `report_blocker` on a run that ended
499
+ * with no outcome means the terminal call was cut off before it executed.
500
+ */
501
+ function readTranscript(messages) {
502
+ const toolCalls = [];
503
+ let finalProse = "";
504
+ for (const message of messages) {
505
+ if (message.role !== "assistant" || typeof message.content === "string") {
506
+ if (message.role === "assistant" && typeof message.content === "string" && message.content.length > 0)
507
+ finalProse = message.content;
508
+ continue;
509
+ }
510
+ for (const part of message.content) {
511
+ if (part.type === "tool-call")
512
+ toolCalls.push(part.toolName);
513
+ // Last one wins: the closing text of the run is what explains a run that
514
+ // stopped talking instead of calling its terminal tool.
515
+ else if (part.type === "text" && part.text.trim().length > 0)
516
+ finalProse = part.text;
517
+ }
518
+ }
519
+ return {
520
+ toolCalls: toolCalls.slice(0, MAX_LOGGED_TOOL_CALLS),
521
+ truncatedToolCalls: toolCalls.length > MAX_LOGGED_TOOL_CALLS,
522
+ finalProse: bounded(finalProse.trim(), MAX_LOGGED_PROSE_CHARS),
523
+ };
524
+ }
525
+ /**
526
+ * The loop half of the finish record: how the planner's run ended, and what it
527
+ * did to get there.
528
+ *
529
+ * Built from the `runToTerminal` result that the tool previously discarded
530
+ * entirely. Without it, `no_outcome` is a single word covering three unrelated
531
+ * failures — a budget spent on rejected submits (`max_iterations`), a planner that
532
+ * answered in prose (`stop`), and a reply cut off at the output-token limit
533
+ * (`length`) — which is why three identical-looking retries taught nobody anything.
534
+ */
535
+ function loopFields(run, kind) {
536
+ const transcript = readTranscript(run.messages);
537
+ return {
538
+ loop: {
539
+ finishReason: run.finish.reason,
540
+ cappedOut: run.finish.cappedOut,
541
+ truncationRecoveries: run.finish.truncationRecoveries,
542
+ salvaged: run.salvage !== null,
543
+ ...(run.salvage ? { firstFinishReason: run.salvage.firstFinish.reason, salvageFinishReason: run.salvage.finish.reason } : {}),
544
+ toolCalls: transcript.toolCalls,
545
+ ...(transcript.truncatedToolCalls ? { toolCallsTruncated: true } : {}),
546
+ },
547
+ ...(PROSE_EXPLAINS_OUTCOME.has(kind) ? modelAuthored({ plannerFinalProse: transcript.finalProse }) : {}),
548
+ };
549
+ }
550
+ /**
551
+ * The endings whose only account is what the planner said last.
552
+ *
553
+ * Everywhere else something better already explains the outcome: a terminal tool
554
+ * recorded its own argument, or a thrown cause carries the fault. Logging the
555
+ * prose there would put model output describing the user's data into a record
556
+ * that had no use for it — including on every successful plan, where the closing
557
+ * text is the planner narrating work that demonstrably went fine.
558
+ */
559
+ const PROSE_EXPLAINS_OUTCOME = new Set(["no_outcome", "loop_error", "timeout"]);
355
560
  /**
356
561
  * Translate the captured `PlannerOutcome` (plus any loop error) into the
357
562
  * `PlanningAgentOutput` contract the conversation agent consumes, alongside the
@@ -471,12 +676,26 @@ export function createGeneratePlanTool(deps) {
471
676
  // the construction-time deps the factory closes over.
472
677
  const logger = baseLogger.with({ analysisId });
473
678
  const startedAt = Date.now();
679
+ const trace = createPlannerTrace();
474
680
  // Every exit routes through here, so "recorded exactly once per invocation" holds
475
681
  // by construction rather than by remembering. `elapsedMs` is what separates a
476
682
  // planner that gave up early from one still working when the guard cut it — the
477
683
  // fixed budget above makes the number readable without any other context.
478
- const finish = (shaped) => {
479
- logger[OUTCOME_LEVEL[shaped.kind]]("plan generation finished", { outcome: shaped.kind, elapsedMs: Date.now() - startedAt });
684
+ //
685
+ // `extra` carries the loop account on the paths that reached the loop. The two
686
+ // parent-plan early returns never do, and a record claiming `loop.finishReason`
687
+ // for a run that never started would be worse than one that omits the field.
688
+ const finish = (shaped, extra = {}) => {
689
+ logger[OUTCOME_LEVEL[shaped.kind]]("plan generation finished", {
690
+ outcome: shaped.kind,
691
+ elapsedMs: Date.now() - startedAt,
692
+ submitAttempts: trace.submitAttempts,
693
+ rejectedAttempts: trace.rejectedAttempts,
694
+ ...(trace.rejections.length > 0 ? { rejections: trace.rejections } : {}),
695
+ ...(trace.rejectedAttempts > trace.rejections.length ? { rejectionsTruncated: true } : {}),
696
+ ...(trace.duplicateTerminalCalls > 0 ? { duplicateTerminalCalls: trace.duplicateTerminalCalls } : {}),
697
+ ...extra,
698
+ });
480
699
  return ok(shaped.output);
481
700
  };
482
701
  // If iterating, load the parent plan so the planner sees what it is
@@ -495,8 +714,15 @@ export function createGeneratePlanTool(deps) {
495
714
  });
496
715
  }
497
716
  priorPlanBlock = formatPriorPlan(input.parentPlanId, priorPlan);
717
+ // `formatPriorPlan` returns null on a stored plan that no longer parses as an
718
+ // `AnalysisPlan`. Iteration then silently becomes generation-from-scratch, and
719
+ // the user gets a plan that ignored everything they asked to keep.
720
+ if (priorPlanBlock === null) {
721
+ logger.warn("prior plan did not parse — iterating without it", { parentPlanId: input.parentPlanId });
722
+ }
498
723
  }
499
- catch {
724
+ catch (err) {
725
+ logger.error("parent plan could not be loaded", { parentPlanId: input.parentPlanId, ...logger.errorFields(err) });
500
726
  return finish({
501
727
  output: { event: "error", error: "Plan iteration failed — parent plan could not be loaded." },
502
728
  kind: "parent_plan_load_failed",
@@ -511,8 +737,19 @@ export function createGeneratePlanTool(deps) {
511
737
  // without dataset facts is a real, supported state (see `renderDataContext`),
512
738
  // so a ledger read that fails must cost the planner its grounding — never
513
739
  // the user their plan.
514
- const profileStatus = await loadDataProfileStatus(deps.pool, analysisId).unwrapOr(null);
515
- const dataContextBlock = renderDataContext(classifyGrounding(profileStatus));
740
+ const profileStatus = await loadDataProfileStatus(deps.pool, analysisId).match((status) => status, (error) => {
741
+ // Degrading to "no profile" is correct behaviour and indistinguishable, from
742
+ // the outcome, from an analysis that genuinely has none — so the read failure
743
+ // is reported here or it is never known to have happened.
744
+ logger.warn("data profile read failed — planning without dataset facts", {
745
+ dbErrorType: error.type,
746
+ ...("op" in error ? { dbOp: error.op } : {}),
747
+ ...("cause" in error ? logger.errorFields(error.cause) : {}),
748
+ });
749
+ return null;
750
+ });
751
+ const grounding = classifyGrounding(profileStatus);
752
+ const dataContextBlock = renderDataContext(grounding);
516
753
  // Reference data and package availability are mandatory grounding,
517
754
  // not optional planner lookups. Read both inventories host-side before
518
755
  // the loop and put their rendered content in the planner's seed.
@@ -522,7 +759,9 @@ export function createGeneratePlanTool(deps) {
522
759
  // tool's recursive leaf scan, so the seed contains usable file paths,
523
760
  // not only top-level directory summaries.
524
761
  const [refsResult, packagesResult] = await Promise.all([listAvailableRefs.execute({ query: "/" }, ctx), listAvailablePackages.execute({}, ctx)]);
525
- const groundingBlock = [inventoryContent("Available Reference Data", refsResult), inventoryContent("Available Packages", packagesResult)].join("\n\n");
762
+ const refsBlock = inventoryContent("Available Reference Data", refsResult, logger);
763
+ const packagesBlock = inventoryContent("Available Packages", packagesResult, logger);
764
+ const groundingBlock = [refsBlock, packagesBlock].join("\n\n");
526
765
  const prompt = [
527
766
  ...(priorPlanBlock ? [priorPlanBlock, ""] : []),
528
767
  ...(dataContextBlock ? [dataContextBlock, ""] : []),
@@ -534,12 +773,39 @@ export function createGeneratePlanTool(deps) {
534
773
  ...(input.priorRuns ? ["", "## Prior Run Results", input.priorRuns] : []),
535
774
  ...(input.userConstraints ? ["", "## User Constraints", input.userConstraints] : []),
536
775
  ].join("\n");
776
+ // Opens the invocation. Two things are only knowable here and both are
777
+ // first-order suspects when a planner runs long and returns nothing: the size
778
+ // of the seed it was handed, and which of its blocks made it that size. The
779
+ // reference inventory in particular is a recursive leaf scan of whatever this
780
+ // install has staged — a quantity the harness does not control and no other
781
+ // record reports. The census is sizes only: the seed carries the user's
782
+ // research question and dataset facts, which belong in the model's context and
783
+ // not in a log file.
784
+ logger.info("plan generation started", {
785
+ model: deps.conversation.model,
786
+ maxIterations: PLANNER_MAX_ITERATIONS,
787
+ salvageIterations: DEFAULT_SALVAGE_ITERATIONS,
788
+ timeoutMs: PLAN_TIMEOUT_MS,
789
+ grounding: grounding.kind,
790
+ ...(input.parentPlanId ? { parentPlanId: input.parentPlanId } : {}),
791
+ seedChars: {
792
+ total: prompt.length,
793
+ priorPlan: priorPlanBlock?.length ?? 0,
794
+ dataContext: dataContextBlock.length,
795
+ referenceData: refsBlock.length,
796
+ packages: packagesBlock.length,
797
+ researchQuestion: input.researchQuestion.length,
798
+ analystNotes: input.analystNotes?.length ?? 0,
799
+ priorRuns: input.priorRuns?.length ?? 0,
800
+ userConstraints: input.userConstraints?.length ?? 0,
801
+ },
802
+ });
537
803
  const holder = { outcome: null };
538
804
  const persistCtx = {
539
805
  analysisId,
540
806
  parentPlanId: input.parentPlanId ?? null,
541
807
  };
542
- const innerTools = buildInnerTools(holder, persistCtx, deps.pool, deps.resourcePolicy, logger);
808
+ const innerTools = buildInnerTools(holder, trace, persistCtx, deps.pool, deps.resourcePolicy, logger);
543
809
  const planner = {
544
810
  id: PLANNER_AGENT_ID,
545
811
  systemPrompt: composeSystemPrompt(plannerInstructions(deps.resourcePolicy)),
@@ -551,8 +817,9 @@ export function createGeneratePlanTool(deps) {
551
817
  // cancels the planner promptly.
552
818
  const signal = AbortSignal.any([ctx.signal, AbortSignal.timeout(PLAN_TIMEOUT_MS)]);
553
819
  let runError = null;
820
+ let run = null;
554
821
  try {
555
- await runToTerminal(planner, [{ role: "user", content: prompt }], forSubAgent(ctx.session, PLANNER_AGENT_ID), {
822
+ run = await runToTerminal(planner, [{ role: "user", content: prompt }], forSubAgent(ctx.session, PLANNER_AGENT_ID), {
556
823
  provider: deps.conversation.provider,
557
824
  signal,
558
825
  emit: ctx.emit,
@@ -573,12 +840,19 @@ export function createGeneratePlanTool(deps) {
573
840
  catch (err) {
574
841
  runError = err;
575
842
  }
576
- return finish(shapeOutcome({
843
+ const shaped = shapeOutcome({
577
844
  holder,
578
845
  runError,
579
846
  timedOut: signal.aborted && !ctx.signal.aborted,
580
847
  outerAborted: ctx.signal.aborted,
581
- }));
848
+ });
849
+ return finish(shaped, {
850
+ // A throw leaves no result to read the transcript off, so the cause takes its
851
+ // place — `shapeOutcome` folds it into one prose sentence for the model, and
852
+ // this is the only place its type and stack survive.
853
+ ...(run ? loopFields(run, shaped.kind) : {}),
854
+ ...(runError ? logger.errorFields(runError) : {}),
855
+ });
582
856
  },
583
857
  });
584
858
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
4
  "description": "Host-agnostic bioinformatics agent harness \u2014 hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -44,7 +44,7 @@
44
44
  "typecheck": "tsc --noEmit"
45
45
  },
46
46
  "dependencies": {
47
- "@ai-sdk/anthropic": "^4.0.15",
47
+ "@ai-sdk/anthropic": "^4.0.20",
48
48
  "@ai-sdk/openai-compatible": "^3.0.10",
49
49
  "@ai-sdk/provider": "^4.0.3",
50
50
  "@ai-sdk/provider-utils": "5.0.10",