@inflexa-ai/harness 0.14.0 → 0.14.1
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/loop/run-agent.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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<
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
336
|
-
|
|
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
|
-
|
|
347
|
-
|
|
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
|
-
|
|
479
|
-
|
|
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).
|
|
515
|
-
|
|
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
|
|
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
|
-
|
|
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