@inflexa-ai/harness 0.44.0 → 0.45.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/agents/conversation-agent.d.ts +10 -0
- package/dist/agents/conversation-agent.js +19 -1
- package/dist/agents/sandbox/shared.d.ts +23 -2
- package/dist/agents/sandbox/shared.js +51 -18
- package/dist/agents/sandbox/translational-safety-agent.js +26 -0
- package/dist/agents/sandbox/types.d.ts +24 -1
- package/dist/agents/sandbox-catalog.d.ts +14 -0
- package/dist/agents/sandbox-catalog.js +19 -0
- package/dist/app/approve-plan.d.ts +51 -0
- package/dist/app/approve-plan.js +84 -0
- package/dist/app/chat-turn.js +6 -3
- package/dist/app/data-profile-view.d.ts +1 -1
- package/dist/app/run-activity.js +4 -6
- package/dist/config/environment-stores.d.ts +4 -2
- package/dist/contracts/chat-events.d.ts +14 -2
- package/dist/contracts/chat-events.js +1 -1
- package/dist/contracts/chat-frame.d.ts +7 -0
- package/dist/contracts/chat-frame.js +24 -6
- package/dist/contracts/chat-parts.d.ts +73 -2
- package/dist/contracts/chat-parts.js +3 -1
- package/dist/contracts/index.d.ts +4 -2
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/part-registry.js +3 -0
- package/dist/contracts/schemas/chat-events.d.ts +38 -0
- package/dist/contracts/schemas/chat-events.js +8 -0
- package/dist/contracts/schemas/chat-parts.d.ts +239 -15
- package/dist/contracts/schemas/chat-parts.js +80 -12
- package/dist/contracts/schemas/index.d.ts +2 -2
- package/dist/contracts/schemas/index.js +2 -2
- package/dist/contracts/subagent-activity.d.ts +12 -0
- package/dist/contracts/subagent-activity.js +76 -0
- package/dist/execution/artifact-metadata.js +1 -3
- package/dist/execution/dbos-workflow-purger.d.ts +2 -0
- package/dist/execution/dbos-workflow-purger.js +21 -14
- package/dist/execution/run-canceler.d.ts +33 -11
- package/dist/execution/run-canceler.js +130 -28
- package/dist/execution/run-end-record.d.ts +30 -0
- package/dist/execution/run-end-record.js +27 -0
- package/dist/execution/run-event-parts.d.ts +6 -5
- package/dist/execution/run-event-parts.js +6 -5
- package/dist/execution/run-event-stream.d.ts +18 -6
- package/dist/execution/run-event-stream.js +67 -17
- package/dist/execution/run-synthesis.d.ts +21 -0
- package/dist/execution/run-synthesis.js +77 -3
- package/dist/index.d.ts +28 -10
- package/dist/index.js +26 -2
- package/dist/lib/hooks.d.ts +1 -1
- package/dist/lib/metrics.d.ts +2 -2
- package/dist/loop/call-guard.d.ts +50 -0
- package/dist/loop/call-guard.js +90 -0
- package/dist/loop/compaction.d.ts +5 -0
- package/dist/loop/compaction.js +17 -0
- package/dist/loop/run-agent.d.ts +9 -0
- package/dist/loop/run-agent.js +16 -1
- package/dist/loop/run-to-terminal.js +4 -1
- package/dist/loop/types.d.ts +8 -0
- package/dist/memory/card-builders.d.ts +11 -0
- package/dist/memory/card-builders.js +35 -21
- package/dist/memory/conversation-display-recorder.d.ts +2 -2
- package/dist/memory/conversation-display-recorder.js +38 -4
- package/dist/memory/conversation-display-storage.d.ts +9 -2
- package/dist/memory/conversation-display-storage.js +18 -3
- package/dist/memory/thread-history.d.ts +42 -8
- package/dist/memory/thread-history.js +164 -45
- package/dist/memory/thread-store.d.ts +13 -0
- package/dist/memory/thread-store.js +15 -1
- package/dist/prompts/briefing.d.ts +57 -3
- package/dist/prompts/briefing.js +129 -2
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +3 -1
- package/dist/prompts/planner.js +13 -2
- package/dist/provenance/seam.d.ts +39 -20
- package/dist/provenance/seam.js +7 -5
- package/dist/providers/ai-sdk.js +93 -11
- package/dist/providers/types.d.ts +6 -0
- package/dist/reference-data/catalog.js +141 -2
- package/dist/report-model/authoring-grammar.d.ts +26 -26
- package/dist/report-model/pin-snapshot.d.ts +1 -1
- package/dist/report-model/pin-snapshot.js +2 -50
- package/dist/runtime/assemble.d.ts +8 -1
- package/dist/runtime/assemble.js +11 -0
- package/dist/runtime/boot.d.ts +3 -0
- package/dist/runtime/boot.js +10 -1
- package/dist/sandbox/image-packages.d.ts +26 -4
- package/dist/sandbox/image-packages.js +18 -4
- package/dist/sandbox/sandbox-step-translate.d.ts +23 -0
- package/dist/sandbox/sandbox-step-translate.js +24 -0
- package/dist/schemas/artifact-manifest.d.ts +1 -1
- package/dist/schemas/artifact-manifest.js +11 -9
- package/dist/schemas/data-profile-schemas.d.ts +12 -12
- package/dist/schemas/plan-schemas.d.ts +75 -21
- package/dist/schemas/plan-schemas.js +1 -1
- package/dist/schemas/run-synthesis.d.ts +2 -2
- package/dist/schemas/workflow-state.d.ts +113 -0
- package/dist/schemas/workflow-state.js +54 -0
- package/dist/state/analyses-metrics.d.ts +79 -7
- package/dist/state/analyses-metrics.js +141 -6
- package/dist/state/artifacts.d.ts +66 -3
- package/dist/state/artifacts.js +85 -7
- package/dist/state/data-profile.d.ts +4 -2
- package/dist/state/data-profile.js +7 -5
- package/dist/state/index.d.ts +9 -8
- package/dist/state/index.js +5 -5
- package/dist/state/migrations/20261012120000_index_the_runs_by_start.d.ts +8 -0
- package/dist/state/migrations/20261012120000_index_the_runs_by_start.js +12 -0
- package/dist/state/migrations/20261013120000_index_the_ended_runs_and_the_recent_threads.d.ts +8 -0
- package/dist/state/migrations/20261013120000_index_the_ended_runs_and_the_recent_threads.js +13 -0
- package/dist/state/migrations/20261013180000_narrow_the_active_run_index.d.ts +8 -0
- package/dist/state/migrations/20261013180000_narrow_the_active_run_index.js +13 -0
- package/dist/state/migrations/20261014120000_index_the_plans_by_parent_and_the_runs_by_plan.d.ts +8 -0
- package/dist/state/migrations/20261014120000_index_the_plans_by_parent_and_the_runs_by_plan.js +13 -0
- package/dist/state/migrations/index.js +8 -0
- package/dist/state/plans.d.ts +21 -0
- package/dist/state/plans.js +25 -0
- package/dist/state/runs.d.ts +79 -14
- package/dist/state/runs.js +150 -33
- package/dist/state/schema.d.ts +2 -0
- package/dist/state/step-executions.d.ts +9 -0
- package/dist/state/step-executions.js +22 -0
- package/dist/tasks/data-profile-activity.d.ts +7 -1
- package/dist/tasks/data-profile-activity.js +18 -9
- package/dist/tasks/data-profile.d.ts +15 -1
- package/dist/tasks/data-profile.js +16 -11
- package/dist/tools/ad-hoc-router.d.ts +2 -0
- package/dist/tools/ad-hoc-router.js +18 -2
- package/dist/tools/approval/gateway.js +28 -5
- package/dist/tools/capability-data.d.ts +36 -0
- package/dist/tools/capability-data.js +73 -0
- package/dist/tools/display/show-user.d.ts +1 -1
- package/dist/tools/execute-analysis.d.ts +80 -1
- package/dist/tools/execute-analysis.js +352 -110
- package/dist/tools/knowledge/check.d.ts +60 -0
- package/dist/tools/knowledge/check.js +92 -0
- package/dist/tools/knowledge/client.d.ts +382 -0
- package/dist/tools/knowledge/client.js +302 -0
- package/dist/tools/knowledge/environment.d.ts +78 -0
- package/dist/tools/knowledge/environment.js +105 -0
- package/dist/tools/knowledge/index.d.ts +49 -0
- package/dist/tools/knowledge/index.js +70 -0
- package/dist/tools/knowledge/local-slots.d.ts +62 -0
- package/dist/tools/knowledge/local-slots.js +177 -0
- package/dist/tools/knowledge/pin.d.ts +14 -0
- package/dist/tools/knowledge/pin.js +17 -0
- package/dist/tools/knowledge/recommend.d.ts +92 -0
- package/dist/tools/knowledge/recommend.js +98 -0
- package/dist/tools/knowledge/situation.d.ts +143 -0
- package/dist/tools/knowledge/situation.js +164 -0
- package/dist/tools/knowledge/skeleton.d.ts +69 -0
- package/dist/tools/knowledge/skeleton.js +202 -0
- package/dist/tools/knowledge/template.d.ts +137 -0
- package/dist/tools/knowledge/template.js +267 -0
- package/dist/tools/research/generate-plan.d.ts +23 -0
- package/dist/tools/research/generate-plan.js +203 -12
- package/dist/tools/research/guarded-address.d.ts +20 -0
- package/dist/tools/research/guarded-address.js +148 -0
- package/dist/tools/research/inspect-data-profile.d.ts +1 -1
- package/dist/tools/research/inspect-run.d.ts +1 -1
- package/dist/tools/research/inspect-run.js +7 -7
- package/dist/tools/research/searxng-engine.d.ts +40 -0
- package/dist/tools/research/searxng-engine.js +120 -0
- package/dist/tools/research/web-fetch.d.ts +124 -0
- package/dist/tools/research/web-fetch.js +627 -0
- package/dist/tools/research/web-search.d.ts +75 -0
- package/dist/tools/research/web-search.js +112 -0
- package/dist/tools/sandbox/list-available-packages.d.ts +7 -4
- package/dist/tools/sandbox/list-available-packages.js +12 -4
- package/dist/tools/sandbox/list-available-refs.d.ts +21 -0
- package/dist/tools/sandbox/list-available-refs.js +60 -3
- package/dist/tools/sandbox/report-blocker.js +5 -1
- package/dist/tools/terminal-text.d.ts +11 -0
- package/dist/tools/terminal-text.js +19 -0
- package/dist/tools/workspace/decision-record.d.ts +81 -0
- package/dist/tools/workspace/decision-record.js +117 -0
- package/dist/tools/workspace/edit-file.d.ts +3 -1
- package/dist/tools/workspace/edit-file.js +20 -2
- package/dist/tools/workspace/execute-command.d.ts +17 -0
- package/dist/tools/workspace/execute-command.js +28 -1
- package/dist/tools/workspace/index.d.ts +1 -0
- package/dist/tools/workspace/index.js +1 -0
- package/dist/tools/workspace/mutator.d.ts +1 -1
- package/dist/workflows/execute-analysis.d.ts +62 -21
- package/dist/workflows/execute-analysis.js +307 -79
- package/dist/workflows/plan/graph.d.ts +16 -0
- package/dist/workflows/plan/graph.js +36 -0
- package/dist/workflows/sandbox-step.d.ts +12 -0
- package/dist/workflows/sandbox-step.js +14 -12
- package/package.json +5 -1
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `knowledge_template` — the model emits slot values, and the tool writes the
|
|
3
|
+
* rendered script and the decision record into the step workspace through
|
|
4
|
+
* the same `WorkspaceMutator` as `write_file`. The script is never output tokens.
|
|
5
|
+
*
|
|
6
|
+
* The installed versions ride from the tool, not from the model: the tool
|
|
7
|
+
* reads the `inflexa.lock` of the farm and the `image-packages.json` of the
|
|
8
|
+
* store when the host names them (`installedPackages`), and the service
|
|
9
|
+
* answers with the environment match, which the decision record keeps.
|
|
10
|
+
*
|
|
11
|
+
* The plan settings ride from the host, not from the model: the step input
|
|
12
|
+
* carries a `TemplateBinding` when the plan step grounds on a template, and
|
|
13
|
+
* the tool merges the bound slots under the model values. A model value that
|
|
14
|
+
* differs from a bound value is refused before the service call unless an
|
|
15
|
+
* `overrides` entry names the slot with a reason, and the decision record
|
|
16
|
+
* keeps the bound slots and the overrides beside the script path.
|
|
17
|
+
*
|
|
18
|
+
* The facts of the machine never ride at all: a local slot of the template
|
|
19
|
+
* (a path, a column name, a level label, a design formula) is split off the
|
|
20
|
+
* request, the service renders it as its marker, and the tool binds the
|
|
21
|
+
* value on the machine before it writes the file. The request carries the
|
|
22
|
+
* step, its claims, and the release digest the plan pinned, thus the record
|
|
23
|
+
* binds the cited decision to the bytes, and a service on another release
|
|
24
|
+
* refuses the render instead of moving the plan in silence. The call runs as
|
|
25
|
+
* one durable step, thus a replay returns the cached result and renders
|
|
26
|
+
* nothing again.
|
|
27
|
+
*/
|
|
28
|
+
import { ok } from "neverthrow";
|
|
29
|
+
import { z } from "zod";
|
|
30
|
+
import { defineTool, withToolCallRecord } from "../define-tool.js";
|
|
31
|
+
import { decisionRecordPath, scriptSha256 } from "../workspace/decision-record.js";
|
|
32
|
+
import { installedPackages } from "./environment.js";
|
|
33
|
+
import { bindLocalSlots, localParameters, validateLocalSlot } from "./local-slots.js";
|
|
34
|
+
const TEMPLATE_REF = /^tpl-[a-z0-9-]+(@\d+\.\d+\.\d+)?$/;
|
|
35
|
+
/** A model value equals a bound value when the scalars are identical, or when two lists hold the same strings in order. */
|
|
36
|
+
function sameValue(bound, value) {
|
|
37
|
+
if (Array.isArray(bound)) {
|
|
38
|
+
return Array.isArray(value) && value.length === bound.length && bound.every((item, index) => item === value[index]);
|
|
39
|
+
}
|
|
40
|
+
return bound === value;
|
|
41
|
+
}
|
|
42
|
+
/** The source of a bound slot as the issue names it. A binding without a source for the slot reads as the plan. */
|
|
43
|
+
function sourceOf(binding, slot) {
|
|
44
|
+
return binding.sources[slot] ?? "plan";
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The refusal of a render request against the binding, or `undefined` when
|
|
48
|
+
* the request obeys it. A different template reference is one issue on the
|
|
49
|
+
* field `template`. Each bound slot whose model value differs without an
|
|
50
|
+
* override is one issue that names the slot, the bound value, and its source.
|
|
51
|
+
*/
|
|
52
|
+
function refusedByBinding(binding, template, slots, overridden) {
|
|
53
|
+
if (template !== binding.template) {
|
|
54
|
+
const message = `the plan binds the template ${binding.template}; this step renders that reference only`;
|
|
55
|
+
return { match: "rejected", message, issues: [{ field: "template", message, permitted: [binding.template] }] };
|
|
56
|
+
}
|
|
57
|
+
const issues = [];
|
|
58
|
+
for (const [slot, bound] of Object.entries(binding.slots)) {
|
|
59
|
+
const value = slots[slot];
|
|
60
|
+
if (value === undefined || sameValue(bound, value) || overridden.has(slot))
|
|
61
|
+
continue;
|
|
62
|
+
const shown = JSON.stringify(bound);
|
|
63
|
+
issues.push({
|
|
64
|
+
slot,
|
|
65
|
+
reason: "bound by the plan",
|
|
66
|
+
message: `the plan binds ${slot} = ${shown} (${sourceOf(binding, slot)}); send that value, or name the slot in overrides with the reason for the change`,
|
|
67
|
+
permitted: [shown],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (issues.length === 0)
|
|
71
|
+
return undefined;
|
|
72
|
+
return { match: "rejected", message: "one or more slot values differ from the plan settings without an override", issues };
|
|
73
|
+
}
|
|
74
|
+
/** A contract answer that is a refusal. The contract itself is a loose object, thus `in` alone cannot tell the two apart. */
|
|
75
|
+
function isContractRefusal(answer) {
|
|
76
|
+
return "match" in answer && (answer.match === "unavailable" || answer.match === "rejected");
|
|
77
|
+
}
|
|
78
|
+
/** The slots of `slots` whose names are in `names`, and the rest, as two records. */
|
|
79
|
+
function splitSlots(slots, names) {
|
|
80
|
+
const local = {};
|
|
81
|
+
const remote = {};
|
|
82
|
+
for (const [name, value] of Object.entries(slots))
|
|
83
|
+
(names.has(name) ? local : remote)[name] = value;
|
|
84
|
+
return { local, remote };
|
|
85
|
+
}
|
|
86
|
+
export function createKnowledgeTemplateTool(deps) {
|
|
87
|
+
return defineTool({
|
|
88
|
+
id: "knowledge_template",
|
|
89
|
+
description: "Render a tested analysis script from a knowledge template and write it into your working directory. " +
|
|
90
|
+
"Use it for a step whose briefing names a template in its Grounding (for example `tpl-deseq2-two-group@1.0.0`). " +
|
|
91
|
+
"The briefing lists the slots of the template and the values the plan binds. " +
|
|
92
|
+
"Send the template id and the slot values only: the file paths of your inputs (absolute `/<analysisId>/...` paths), the column names, the levels of the contrast, and the design. " +
|
|
93
|
+
"A slot the briefing marks local (a path, a column name, a level, a formula) is bound on this machine and never sent to the service; send it as any other slot. " +
|
|
94
|
+
"A bound value rides into the script as it is; send it unchanged, or omit it. To change a bound value, send the new value and add an `overrides` entry with the slot and the reason. A changed value without an override is refused before the service call. " +
|
|
95
|
+
"Prefer the template over a script of your own: it is tested, and it carries the settings of the plan. When the template does not fit the step (a design it does not cover, or an input it cannot read), write the script yourself with `write_file`, and state the reason in your summary. " +
|
|
96
|
+
"The tool writes `scripts/<template>.R` or `scripts/<template>.py` and `output/decision_record_<script>.json`, one record per rendered script (the step, its claims, the template, the snapshot, each slot with its source, the bound slots, the overrides, the environment match, the citations, and the digest of the script as written), then you run the script with `execute_command` using the command in `run_with`. " +
|
|
97
|
+
"A slot value the template refuses comes back as `match: rejected` with the slot and the permitted values; correct it and call again. " +
|
|
98
|
+
"A change the slots do not cover: use `edit_file` on a line marked `# [adaptable: ...]` in the rendered script, and keep the edit small; the record lists each such edit. " +
|
|
99
|
+
"`match: unavailable` means the service did not answer, and `match: snapshot_mismatch` means the service moved to another release than the plan pinned; in both cases write the script yourself as you would without this tool, and state it in your summary.",
|
|
100
|
+
inputSchema: z.object({
|
|
101
|
+
template: z
|
|
102
|
+
.string()
|
|
103
|
+
.regex(TEMPLATE_REF, "a template id, optionally with @version")
|
|
104
|
+
.describe("The template id from the Grounding of the step, with its version, for example `tpl-deseq2-two-group@1.0.0`."),
|
|
105
|
+
slots: z
|
|
106
|
+
.record(z.string(), z.unknown())
|
|
107
|
+
.describe("The slot values, by slot name. Only the adaptable slots of the template; a pinned slot is refused. Strings, numbers, booleans, and lists of strings."),
|
|
108
|
+
overrides: z
|
|
109
|
+
.array(z.object({
|
|
110
|
+
slot: z.string().min(1).describe("The name of the bound slot whose value you change."),
|
|
111
|
+
reason: z.string().min(1).describe("Why the plan value does not hold for this step, in one sentence."),
|
|
112
|
+
}))
|
|
113
|
+
.optional()
|
|
114
|
+
.describe("One entry per bound slot whose value you change from the plan value. Absent when you send every bound value as it is."),
|
|
115
|
+
script_name: z
|
|
116
|
+
.string()
|
|
117
|
+
.regex(/^[A-Za-z0-9_.-]+$/)
|
|
118
|
+
.optional()
|
|
119
|
+
.describe("The file name under `scripts/`. Defaults to `<template id>.R` or `<template id>.py` by the language of the template."),
|
|
120
|
+
}),
|
|
121
|
+
describeCall: ({ template }) => template,
|
|
122
|
+
describeResult: (_input, result) => "status" in result ? (result.status === "ok" ? `${result.script_path} (${result.environment_match})` : result.status) : result.match,
|
|
123
|
+
execute: async ({ template, slots, overrides, script_name }, ctx) => {
|
|
124
|
+
const binding = deps.binding;
|
|
125
|
+
const reasons = new Map((overrides ?? []).map((entry) => [entry.slot, entry.reason]));
|
|
126
|
+
if (binding) {
|
|
127
|
+
const refused = refusedByBinding(binding, template, slots, new Set(reasons.keys()));
|
|
128
|
+
if (refused)
|
|
129
|
+
return ok(refused);
|
|
130
|
+
}
|
|
131
|
+
// The local slots and the adaptable names come from the binding the host composed at dispatch, else from
|
|
132
|
+
// the contract itself. Without them no free-text value can be told from a fact of the machine, and an
|
|
133
|
+
// unknown name could carry one, thus no render happens without them.
|
|
134
|
+
let locals;
|
|
135
|
+
let adaptable;
|
|
136
|
+
if (binding?.local && binding.adaptable) {
|
|
137
|
+
locals = binding.local;
|
|
138
|
+
adaptable = binding.adaptable;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
const contract = await deps.client.contract(template);
|
|
142
|
+
if (isContractRefusal(contract))
|
|
143
|
+
return ok(contract);
|
|
144
|
+
locals = localParameters(contract.parameters);
|
|
145
|
+
adaptable = contract.parameters.filter((parameter) => parameter.adaptable).map((parameter) => parameter.name);
|
|
146
|
+
}
|
|
147
|
+
const unknown = Object.keys(slots).filter((name) => !adaptable.includes(name));
|
|
148
|
+
if (unknown.length > 0) {
|
|
149
|
+
return ok({
|
|
150
|
+
match: "rejected",
|
|
151
|
+
message: "one or more slot names are not adaptable slots of this template",
|
|
152
|
+
issues: unknown.map((slot) => ({ slot, reason: "the template has no such adaptable slot", permitted: [...adaptable] })),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const localNames = new Set(locals.map((parameter) => parameter.name));
|
|
156
|
+
const split = splitSlots(slots, localNames);
|
|
157
|
+
const boundSplit = splitSlots(binding?.slots ?? {}, localNames);
|
|
158
|
+
const merged = { ...boundSplit.remote, ...split.remote };
|
|
159
|
+
// A bound local slot is bound on the machine like a model value, under the model value.
|
|
160
|
+
const localValues = { ...boundSplit.local, ...split.local };
|
|
161
|
+
const localByName = new Map(locals.map((parameter) => [parameter.name, parameter]));
|
|
162
|
+
// A local value the contract refuses is refused before the service call, thus a bad path costs no render.
|
|
163
|
+
const localIssues = Object.entries(localValues).flatMap(([name, value]) => {
|
|
164
|
+
const issue = validateLocalSlot(localByName.get(name), value);
|
|
165
|
+
return issue ? [issue] : [];
|
|
166
|
+
});
|
|
167
|
+
if (localIssues.length > 0) {
|
|
168
|
+
return ok({ match: "rejected", message: "one or more local slot values are not valid for this template", issues: localIssues });
|
|
169
|
+
}
|
|
170
|
+
const farmLockFile = typeof deps.farmLockFile === "function" ? deps.farmLockFile(ctx.session.scope.analysisId) : deps.farmLockFile;
|
|
171
|
+
const installed = installedPackages({
|
|
172
|
+
...(farmLockFile ? { farmLockFile } : {}),
|
|
173
|
+
...(deps.imagePackagesFile ? { imagePackagesFile: deps.imagePackagesFile } : {}),
|
|
174
|
+
});
|
|
175
|
+
const answer = await deps.client.render(template, merged, installed.packages, {
|
|
176
|
+
...(binding?.step ? { step: binding.step } : {}),
|
|
177
|
+
...(binding?.claims ? { claims: binding.claims } : {}),
|
|
178
|
+
...(binding?.snapshot ? { expectedSnapshot: binding.snapshot } : {}),
|
|
179
|
+
});
|
|
180
|
+
// A rendered answer carries `ok: true`; the refusals carry `match` and no `ok`.
|
|
181
|
+
if (!("ok" in answer))
|
|
182
|
+
return ok(answer);
|
|
183
|
+
const bound = bindLocalSlots({ language: answer.template.language, parameters: locals }, answer.script, localValues, answer.slots);
|
|
184
|
+
if (!bound.ok) {
|
|
185
|
+
return ok({ match: "rejected", message: "one or more local slot values are not valid for this template", issues: bound.issues });
|
|
186
|
+
}
|
|
187
|
+
const scriptFile = script_name ?? `${answer.template.id}.${answer.template.language === "R" ? "R" : "py"}`;
|
|
188
|
+
const scriptPath = `scripts/${scriptFile}`;
|
|
189
|
+
// Each landed write rides the call record, also when a later write is refused.
|
|
190
|
+
const records = [];
|
|
191
|
+
const write = async (path, content) => {
|
|
192
|
+
const outcome = await deps.mutator.writeFile({
|
|
193
|
+
path,
|
|
194
|
+
content,
|
|
195
|
+
toolName: "knowledge_template",
|
|
196
|
+
invocationId: ctx.invocationId,
|
|
197
|
+
session: ctx.session,
|
|
198
|
+
});
|
|
199
|
+
if (outcome.status === "ok")
|
|
200
|
+
records.push(outcome.record);
|
|
201
|
+
return outcome;
|
|
202
|
+
};
|
|
203
|
+
const scriptWrite = await write(scriptPath, bound.script);
|
|
204
|
+
if (scriptWrite.status !== "ok")
|
|
205
|
+
return ok({ status: "write_refused", path: scriptWrite.path, reason: scriptWrite.status });
|
|
206
|
+
const boundSlots = Object.entries(binding?.slots ?? {}).map(([slot, value]) => ({
|
|
207
|
+
slot,
|
|
208
|
+
value: value,
|
|
209
|
+
source: binding ? sourceOf(binding, slot) : "plan",
|
|
210
|
+
}));
|
|
211
|
+
const settingsOverrides = boundSlots.flatMap(({ slot, value }) => {
|
|
212
|
+
const reason = reasons.get(slot);
|
|
213
|
+
const sent = slots[slot];
|
|
214
|
+
if (reason === undefined || sent === undefined || sameValue(value, sent))
|
|
215
|
+
return [];
|
|
216
|
+
return [{ slot, plan_value: value, new_value: sent, reason }];
|
|
217
|
+
});
|
|
218
|
+
// The slot report of the record: the service entries, with each local marker entry replaced by the slot as
|
|
219
|
+
// bound here. An optional local slot with no value has no entry, as in a full render.
|
|
220
|
+
const boundByName = new Map(bound.slots.map((slot) => [slot.name, slot]));
|
|
221
|
+
const reportedSlots = answer.slots.flatMap((slot) => {
|
|
222
|
+
if (slot.source !== "local")
|
|
223
|
+
return [slot];
|
|
224
|
+
const local = boundByName.get(slot.name);
|
|
225
|
+
return local ? [local] : [];
|
|
226
|
+
});
|
|
227
|
+
const record = {
|
|
228
|
+
...answer.decision_record,
|
|
229
|
+
slots: reportedSlots,
|
|
230
|
+
script_path: scriptWrite.path,
|
|
231
|
+
written_sha256: scriptSha256(bound.script),
|
|
232
|
+
bound_slots: boundSlots,
|
|
233
|
+
settings_overrides: settingsOverrides,
|
|
234
|
+
};
|
|
235
|
+
const recordWrite = await write(decisionRecordPath(scriptFile), `${JSON.stringify(record, null, 2)}\n`);
|
|
236
|
+
if (recordWrite.status !== "ok") {
|
|
237
|
+
return ok(withToolCallRecord({ status: "write_refused", path: recordWrite.path, reason: recordWrite.status }, records));
|
|
238
|
+
}
|
|
239
|
+
return ok(withToolCallRecord({
|
|
240
|
+
status: "ok",
|
|
241
|
+
script_path: scriptWrite.path,
|
|
242
|
+
decision_record_path: recordWrite.path,
|
|
243
|
+
template: {
|
|
244
|
+
id: answer.template.id,
|
|
245
|
+
version: answer.template.version,
|
|
246
|
+
label: answer.template.label,
|
|
247
|
+
method: { id: answer.template.method.id, label: answer.template.method.label },
|
|
248
|
+
...(answer.template.substitute_for
|
|
249
|
+
? { substitute_for: { id: answer.template.substitute_for.id, label: answer.template.substitute_for.label } }
|
|
250
|
+
: {}),
|
|
251
|
+
},
|
|
252
|
+
snapshot: answer.snapshot,
|
|
253
|
+
slots: reportedSlots,
|
|
254
|
+
local_slots: bound.slots.map((slot) => slot.name),
|
|
255
|
+
written_sha256: record.written_sha256,
|
|
256
|
+
environment_match: answer.environment.match,
|
|
257
|
+
syntax: answer.syntax.status,
|
|
258
|
+
expected_outputs: answer.outputs,
|
|
259
|
+
run_with: `${answer.template.language === "R" ? "Rscript" : "python3"} ${scriptPath}`,
|
|
260
|
+
}, records));
|
|
261
|
+
},
|
|
262
|
+
foldCallRecord: (records) => {
|
|
263
|
+
for (const record of records)
|
|
264
|
+
deps.mutator.recordWrite(record);
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
}
|
|
@@ -30,6 +30,7 @@ import type { UsageRecorder } from "../../billing/usage-recorder.js";
|
|
|
30
30
|
import { type Tool } from "../define-tool.js";
|
|
31
31
|
import type { EnvironmentStorePaths } from "../../config/environment-stores.js";
|
|
32
32
|
import { type BioToolKeys } from "../bio/keys.js";
|
|
33
|
+
import { type KnowledgeClient, type KnowledgeRecommendAnswer } from "../knowledge/index.js";
|
|
33
34
|
import { type ResourcePolicy } from "../../config/resource-limits.js";
|
|
34
35
|
import type { Logger } from "../../lib/logger.js";
|
|
35
36
|
export interface GeneratePlanDeps extends EnvironmentStorePaths {
|
|
@@ -58,6 +59,28 @@ export interface GeneratePlanDeps extends EnvironmentStorePaths {
|
|
|
58
59
|
readonly toolOutputStore?: ToolOutputStore;
|
|
59
60
|
/** API keys for the search tools the planner uses to ground a plan. */
|
|
60
61
|
readonly bioKeys: BioToolKeys;
|
|
62
|
+
/**
|
|
63
|
+
* The knowledge service client. Bound, the planner gains `knowledge_recommend`
|
|
64
|
+
* and `knowledge_check`. Absent, no knowledge tool attaches and no
|
|
65
|
+
* description of one enters the context, which is the default state of
|
|
66
|
+
* the open-source host.
|
|
67
|
+
*/
|
|
68
|
+
readonly knowledge?: KnowledgeClient;
|
|
61
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* The search tools of the planner — built one time, not per invocation.
|
|
72
|
+
*
|
|
73
|
+
* The set answers the two questions that a seed cannot. "What did a study of
|
|
74
|
+
* this kind do before?" reaches the public dataset records and the literature.
|
|
75
|
+
* "What does this package actually give me?" reaches the developer docs. The
|
|
76
|
+
* planner reads its reference and package inventories from the seed, thus the
|
|
77
|
+
* docs pair is what turns a package name into a real function.
|
|
78
|
+
*
|
|
79
|
+
* A tool here never writes and never computes. Thus the worst outcome of a
|
|
80
|
+
* needless call is latency, and the prompt is what bounds that.
|
|
81
|
+
*/
|
|
82
|
+
export declare function buildPlannerSearchTools(deps: GeneratePlanDeps, hooks?: {
|
|
83
|
+
readonly onRecommend?: (answer: KnowledgeRecommendAnswer) => void;
|
|
84
|
+
}): Tool[];
|
|
62
85
|
/** Build the `generate_plan` tool bound to its provider and pool. */
|
|
63
86
|
export declare function createGeneratePlanTool(deps: GeneratePlanDeps): Tool;
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* `Session`. The planner prompt injects the agent catalog and exposes the
|
|
24
24
|
* terminal-tool surface above.
|
|
25
25
|
*/
|
|
26
|
-
import { ok } from "neverthrow";
|
|
26
|
+
import { err, 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";
|
|
@@ -36,21 +36,24 @@ import { createReadToolOutputTool } from "../read-tool-output.js";
|
|
|
36
36
|
import { createListAvailablePackagesTool } from "../sandbox/list-available-packages.js";
|
|
37
37
|
import { createListAvailableRefsTool } from "../sandbox/list-available-refs.js";
|
|
38
38
|
import { createReportBlockerToolFor } from "../sandbox/report-blocker.js";
|
|
39
|
+
import { degenerateTerminalText } from "../terminal-text.js";
|
|
39
40
|
import { searchGeoDatasetsTool } from "../bio/search-geo-datasets.js";
|
|
40
41
|
import { createNcbiTools } from "../bio/keys.js";
|
|
42
|
+
import { createKnowledgeTools } from "../knowledge/index.js";
|
|
41
43
|
import { queryDocsTool, resolveLibraryIdTool } from "./context7-docs.js";
|
|
42
44
|
import { searchArxivTool } from "./search-arxiv.js";
|
|
43
45
|
import { createSearchGithubReposTool } from "./search-github-repos.js";
|
|
44
46
|
import { createSearchSemanticScholarTool } from "./search-semantic-scholar.js";
|
|
45
47
|
import { DATA_PROFILE_ORIENTATION_MAX_CHARS, buildDataProfileOrientation } from "../../app/data-profile-orientation.js";
|
|
46
48
|
import { DEFAULT_SANDBOX_MAX_STEPS } from "../../config/resource-limits.js";
|
|
49
|
+
import { guardRepeatedCalls } from "../../loop/call-guard.js";
|
|
47
50
|
import { plannerPrompt } from "../../prompts/planner.js";
|
|
48
51
|
import { hydratePlanSteps, PlannerPlanSchema } from "../../schemas/plan-schemas.js";
|
|
49
52
|
import { validatePlan } from "../../schemas/validate-plan.js";
|
|
50
53
|
import { AnalysisPlanSchema } from "../../schemas/workflow-state.js";
|
|
51
54
|
import { createNoopLogger } from "../../lib/console-logger.js";
|
|
52
55
|
import { unwrapOrThrow } from "../../lib/result.js";
|
|
53
|
-
import { hintForZodIssue } from "../../lib/zod-issues.js";
|
|
56
|
+
import { decodeObjectString, hintForZodIssue } from "../../lib/zod-issues.js";
|
|
54
57
|
import { insertPlan, loadDataProfileStatus, loadPlan } from "../../state/index.js";
|
|
55
58
|
// ── Tool-level config ──────────────────────────────────────────────
|
|
56
59
|
/** Sub-agent identity for the planner — provenance only. */
|
|
@@ -69,6 +72,14 @@ const PLANNER_AGENT_ID = "planner";
|
|
|
69
72
|
const PLANNER_MAX_ITERATIONS = 300;
|
|
70
73
|
/** Wall-clock guard for a single plan-generation invocation. */
|
|
71
74
|
const PLAN_TIMEOUT_MS = 1_800_000;
|
|
75
|
+
/**
|
|
76
|
+
* The refusals of the call guard that end the search phase of one plan. A
|
|
77
|
+
* planner that the guard refused this many times is not searching, it is
|
|
78
|
+
* looping, and the wrap-up plus the salvage turn give it the plan it has. Six
|
|
79
|
+
* is above any count a frontier planner reached in the Phase 0 campaign
|
|
80
|
+
* (zero) and far below the hundred-plus refused calls of a looping run.
|
|
81
|
+
*/
|
|
82
|
+
const PLANNER_REFUSAL_LIMIT = 6;
|
|
72
83
|
// ── Diagnostic bounds ───────────────────────────────────────────────
|
|
73
84
|
//
|
|
74
85
|
// This tool runs on `passthroughStep`: it writes no ledger row and owns no
|
|
@@ -297,14 +308,150 @@ function zodIssuesToValidationIssues(error, input, rootPath = "plan") {
|
|
|
297
308
|
hint: hintForZodIssue(i, input),
|
|
298
309
|
}));
|
|
299
310
|
}
|
|
311
|
+
function rememberAnswer(memory, answer) {
|
|
312
|
+
memory.steps.clear();
|
|
313
|
+
memory.claims.clear();
|
|
314
|
+
memory.digest = answer.snapshot.digest;
|
|
315
|
+
for (const step of answer.plan_skeleton) {
|
|
316
|
+
memory.steps.set(step.id, step);
|
|
317
|
+
for (const claim of step.grounding.claims)
|
|
318
|
+
memory.claims.add(claim);
|
|
319
|
+
for (const alternative of step.alternatives)
|
|
320
|
+
for (const rule of alternative.rules)
|
|
321
|
+
memory.claims.add(rule);
|
|
322
|
+
if (step.disputed !== undefined)
|
|
323
|
+
memory.claims.add(step.disputed.rule);
|
|
324
|
+
}
|
|
325
|
+
for (const claim of answer.claims)
|
|
326
|
+
memory.claims.add(claim.id);
|
|
327
|
+
for (const flag of answer.flags)
|
|
328
|
+
memory.claims.add(flag.rule);
|
|
329
|
+
}
|
|
330
|
+
/** The rule of a claim id: `R-0001` of `R-0001@e7d0`. */
|
|
331
|
+
function ruleOf(claim) {
|
|
332
|
+
return claim.split("@")[0] ?? claim;
|
|
333
|
+
}
|
|
334
|
+
/** One claim id per rule of the answer. A rule with two ids in one answer cannot be repaired, and it is absent. */
|
|
335
|
+
function claimsByRule(claims) {
|
|
336
|
+
const byRule = new Map();
|
|
337
|
+
const ambiguous = new Set();
|
|
338
|
+
for (const claim of claims) {
|
|
339
|
+
const rule = ruleOf(claim);
|
|
340
|
+
if (byRule.has(rule))
|
|
341
|
+
ambiguous.add(rule);
|
|
342
|
+
else
|
|
343
|
+
byRule.set(rule, claim);
|
|
344
|
+
}
|
|
345
|
+
for (const rule of ambiguous)
|
|
346
|
+
byRule.delete(rule);
|
|
347
|
+
return byRule;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Restore the grounding fields the host owns on each candidate step, from the
|
|
351
|
+
* last answer. A model copies the skeleton into the plan, and it drops the
|
|
352
|
+
* template field at times, or it miscopies the 64-character digest or the
|
|
353
|
+
* hash of a claim id, while it keeps the claims and the settings.
|
|
354
|
+
*
|
|
355
|
+
* On a step that copies a skeleton step, by step id, the host stamps the
|
|
356
|
+
* snapshot digest of the answer and restores a dropped template: the digest
|
|
357
|
+
* is what pins the step to the snapshot, and the template is what the host
|
|
358
|
+
* binds the contract to. A step whose snapshot is `none` states that it does
|
|
359
|
+
* not use the answer, and it stays as it is. A step whose status is
|
|
360
|
+
* `ungrounded` states a departure from the answer: the digest is stamped,
|
|
361
|
+
* and no template is restored, because the template realizes the method the
|
|
362
|
+
* step left.
|
|
363
|
+
*
|
|
364
|
+
* On every step, a claim id the answer did not return is repaired when the
|
|
365
|
+
* answer holds one claim of the same rule: one snapshot holds one version of
|
|
366
|
+
* a rule, thus the rule names the claim. A claim of a rule the answer does
|
|
367
|
+
* not hold is an issue that rejects the plan, so the model cites a claim of
|
|
368
|
+
* the answer or removes it.
|
|
369
|
+
*
|
|
370
|
+
* A step the skeleton does not hold, for example a robustness step the model
|
|
371
|
+
* adds under its own id, gets the digest stamped too when one of its claims
|
|
372
|
+
* is a claim of the answer: the claim vouches that the step consulted the
|
|
373
|
+
* answer, and the digest it typed is as prone to a miscopy as any other. A
|
|
374
|
+
* step with no claim of the answer stays as the model wrote it, because
|
|
375
|
+
* nothing of the answer vouches for it. Returns the stamped steps, the
|
|
376
|
+
* restored steps, the repaired claims, and the issues beside the new
|
|
377
|
+
* candidate. A candidate that is not a plan-shaped object, or a plan with no
|
|
378
|
+
* answer, comes back as it is.
|
|
379
|
+
*/
|
|
380
|
+
function restoreSkeletonGrounding(candidate, memory) {
|
|
381
|
+
const decoded = decodeObjectString(candidate);
|
|
382
|
+
if (memory.steps.size === 0 || typeof decoded !== "object" || decoded === null || !Array.isArray(decoded.steps)) {
|
|
383
|
+
return { candidate: decoded, stamped: [], restored: [], repaired: [], unknown: [] };
|
|
384
|
+
}
|
|
385
|
+
const byRule = claimsByRule(memory.claims);
|
|
386
|
+
const stamped = [];
|
|
387
|
+
const restored = [];
|
|
388
|
+
const repaired = [];
|
|
389
|
+
const unknown = [];
|
|
390
|
+
const steps = decoded.steps.map((step, index) => {
|
|
391
|
+
if (typeof step !== "object" || step === null)
|
|
392
|
+
return step;
|
|
393
|
+
const { id, grounding } = step;
|
|
394
|
+
if (typeof grounding !== "object" || grounding === null)
|
|
395
|
+
return step;
|
|
396
|
+
const current = grounding;
|
|
397
|
+
const label = typeof id === "string" ? id : String(index);
|
|
398
|
+
let next = current;
|
|
399
|
+
const source = typeof id === "string" ? memory.steps.get(id) : undefined;
|
|
400
|
+
if (source !== undefined && current.snapshot !== "none") {
|
|
401
|
+
if (next.snapshot !== source.grounding.snapshot) {
|
|
402
|
+
stamped.push(label);
|
|
403
|
+
next = { ...next, snapshot: source.grounding.snapshot };
|
|
404
|
+
}
|
|
405
|
+
if (next.template === undefined && source.grounding.template !== undefined && current.status !== "ungrounded") {
|
|
406
|
+
restored.push(label);
|
|
407
|
+
next = { ...next, template: source.grounding.template };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (Array.isArray(current.claims)) {
|
|
411
|
+
let changed = false;
|
|
412
|
+
const claims = current.claims.map((claim, position) => {
|
|
413
|
+
if (typeof claim !== "string" || memory.claims.has(claim))
|
|
414
|
+
return claim;
|
|
415
|
+
const match = byRule.get(ruleOf(claim));
|
|
416
|
+
if (match !== undefined) {
|
|
417
|
+
repaired.push({ step: label, from: claim, to: match });
|
|
418
|
+
changed = true;
|
|
419
|
+
return match;
|
|
420
|
+
}
|
|
421
|
+
unknown.push({
|
|
422
|
+
path: `plan.steps.${index}.grounding.claims.${position}`,
|
|
423
|
+
code: "semantic",
|
|
424
|
+
message: `Claim ${claim} is not a claim of the knowledge_recommend answer of this run. Cite a claim id the answer returned, or remove it.`,
|
|
425
|
+
});
|
|
426
|
+
return claim;
|
|
427
|
+
});
|
|
428
|
+
if (changed)
|
|
429
|
+
next = { ...next, claims };
|
|
430
|
+
}
|
|
431
|
+
// The repaired claims are the ones compared, thus a step whose only claim was miscopied still gets the digest.
|
|
432
|
+
const vouched = Array.isArray(next.claims) && next.claims.some((claim) => typeof claim === "string" && memory.claims.has(claim));
|
|
433
|
+
if (source === undefined && vouched && memory.digest !== undefined && current.snapshot !== "none" && next.snapshot !== memory.digest) {
|
|
434
|
+
stamped.push(label);
|
|
435
|
+
next = { ...next, snapshot: memory.digest };
|
|
436
|
+
}
|
|
437
|
+
return next === current ? step : { ...step, grounding: next };
|
|
438
|
+
});
|
|
439
|
+
return { candidate: { ...decoded, steps }, stamped, restored, repaired, unknown };
|
|
440
|
+
}
|
|
300
441
|
/**
|
|
301
442
|
* Full validation: Zod schema + semantic checks. The plan is valid only if
|
|
302
443
|
* BOTH pass.
|
|
303
444
|
*/
|
|
304
445
|
function fullyValidate(candidate, resourcePolicy) {
|
|
305
|
-
|
|
446
|
+
// The permissive arg schema of the planner tools accepts a string, thus the
|
|
447
|
+
// loop-boundary repair never sees a plan that a model sent as a JSON-encoded
|
|
448
|
+
// string. A small model does this on a large nested schema. Decode it here,
|
|
449
|
+
// the same as run-synthesis does, so the schema issues describe the plan
|
|
450
|
+
// inside the string and not the string.
|
|
451
|
+
const decoded = decodeObjectString(candidate);
|
|
452
|
+
const parsed = PlannerPlanSchema.safeParse(decoded);
|
|
306
453
|
if (!parsed.success) {
|
|
307
|
-
return { valid: false, issues: zodIssuesToValidationIssues(parsed.error,
|
|
454
|
+
return { valid: false, issues: zodIssuesToValidationIssues(parsed.error, decoded) };
|
|
308
455
|
}
|
|
309
456
|
// Semantic checks operate on the AnalysisPlan shape — PlannerPlan omits
|
|
310
457
|
// maxSteps, so inject the execution-time default to satisfy the validator.
|
|
@@ -336,7 +483,7 @@ function fullyValidate(candidate, resourcePolicy) {
|
|
|
336
483
|
* shared `holder` so the outer `execute` reads the terminal outcome after the
|
|
337
484
|
* loop finishes.
|
|
338
485
|
*/
|
|
339
|
-
function buildInnerTools(holder, trace, persistCtx, pool, resourcePolicy, logger) {
|
|
486
|
+
function buildInnerTools(holder, trace, persistCtx, pool, resourcePolicy, logger, memory) {
|
|
340
487
|
const submitPlanTool = defineTool({
|
|
341
488
|
id: "submit_plan",
|
|
342
489
|
description: "Submit the final plan for persistence. Re-validates the plan; on " +
|
|
@@ -375,10 +522,20 @@ function buildInnerTools(holder, trace, persistCtx, pool, resourcePolicy, logger
|
|
|
375
522
|
});
|
|
376
523
|
}
|
|
377
524
|
const attempt = ++trace.submitAttempts;
|
|
378
|
-
const
|
|
379
|
-
if (
|
|
525
|
+
const { candidate, stamped, restored, repaired, unknown } = restoreSkeletonGrounding(input.plan, memory);
|
|
526
|
+
if (stamped.length > 0)
|
|
527
|
+
logger.info("submit_plan stamped the snapshot digest of the answer on steps", { steps: stamped });
|
|
528
|
+
if (restored.length > 0)
|
|
529
|
+
logger.info("submit_plan restored the template of skeleton steps", { steps: restored });
|
|
530
|
+
if (repaired.length > 0)
|
|
531
|
+
logger.info("submit_plan repaired the claim ids of plan steps", { claims: repaired });
|
|
532
|
+
const result = fullyValidate(candidate, resourcePolicy);
|
|
533
|
+
// An unknown claim rejects a plan that the schema accepts, beside the
|
|
534
|
+
// schema issues when the schema rejects it, thus one attempt shows both.
|
|
535
|
+
if (!result.valid || unknown.length > 0) {
|
|
536
|
+
const issues = [...(result.valid ? [] : result.issues), ...unknown];
|
|
380
537
|
trace.rejectedAttempts++;
|
|
381
|
-
const rejection = toRejectionRecord(attempt,
|
|
538
|
+
const rejection = toRejectionRecord(attempt, issues);
|
|
382
539
|
if (trace.rejections.length < MAX_LOGGED_REJECTIONS)
|
|
383
540
|
trace.rejections.push(rejection);
|
|
384
541
|
// `warn`, not `debug`: a rejection is non-terminal by design, so the model
|
|
@@ -386,7 +543,7 @@ function buildInnerTools(holder, trace, persistCtx, pool, resourcePolicy, logger
|
|
|
386
543
|
// an attempt was spent. A run that exhausts its budget on rejections ends
|
|
387
544
|
// looking identical to one that never tried, and this is the difference.
|
|
388
545
|
logger.warn("submit_plan rejected a plan", { ...rejection });
|
|
389
|
-
return ok({ accepted: false, issues
|
|
546
|
+
return ok({ accepted: false, issues });
|
|
390
547
|
}
|
|
391
548
|
const persisted = await persistPlan(result.plan, persistCtx, pool, logger);
|
|
392
549
|
if (!persisted.ok) {
|
|
@@ -431,6 +588,11 @@ function buildInnerTools(holder, trace, persistCtx, pool, resourcePolicy, logger
|
|
|
431
588
|
}),
|
|
432
589
|
describeCall: "none",
|
|
433
590
|
execute: async (input) => {
|
|
591
|
+
const degenerate = degenerateTerminalText(input.question);
|
|
592
|
+
if (degenerate) {
|
|
593
|
+
logger.warn("request_clarification refused a placeholder question", { question: input.question.slice(0, 80) });
|
|
594
|
+
return err({ error: `${degenerate} Ask the real question, or continue: submit the plan you have.`, retryable: true });
|
|
595
|
+
}
|
|
434
596
|
if (holder.outcome !== null) {
|
|
435
597
|
trace.duplicateTerminalCalls++;
|
|
436
598
|
logger.warn("request_clarification called after a terminal outcome was recorded", {
|
|
@@ -657,7 +819,7 @@ const OUTCOME_LEVEL = {
|
|
|
657
819
|
* A tool here never writes and never computes. Thus the worst outcome of a
|
|
658
820
|
* needless call is latency, and the prompt is what bounds that.
|
|
659
821
|
*/
|
|
660
|
-
function buildPlannerSearchTools(deps) {
|
|
822
|
+
export function buildPlannerSearchTools(deps, hooks = {}) {
|
|
661
823
|
const bioKeys = deps.bioKeys;
|
|
662
824
|
const ncbi = createNcbiTools(bioKeys);
|
|
663
825
|
return [
|
|
@@ -687,6 +849,15 @@ function buildPlannerSearchTools(deps) {
|
|
|
687
849
|
...(deps.imagePackagesFile === undefined ? {} : { imagePackagesFile: deps.imagePackagesFile }),
|
|
688
850
|
...(deps.readPoolInventory === undefined ? {} : { readPoolInventory: deps.readPoolInventory }),
|
|
689
851
|
}),
|
|
852
|
+
// The knowledge plane: one cited procedure per situation, and one check
|
|
853
|
+
// of the draft. Both attach only when the embedder binds a client.
|
|
854
|
+
...createKnowledgeTools({
|
|
855
|
+
...(deps.knowledge === undefined ? {} : { client: deps.knowledge }),
|
|
856
|
+
...(deps.farmLockFile === undefined ? {} : { farmLockFile: deps.farmLockFile }),
|
|
857
|
+
...(deps.imagePackagesFile === undefined ? {} : { imagePackagesFile: deps.imagePackagesFile }),
|
|
858
|
+
...(deps.refStorePath === undefined ? {} : { refStorePath: deps.refStorePath }),
|
|
859
|
+
...(hooks.onRecommend === undefined ? {} : { onRecommend: hooks.onRecommend }),
|
|
860
|
+
}),
|
|
690
861
|
];
|
|
691
862
|
}
|
|
692
863
|
/** Build the `generate_plan` tool bound to its provider and pool. */
|
|
@@ -872,12 +1043,28 @@ export function createGeneratePlanTool(deps) {
|
|
|
872
1043
|
analysisId,
|
|
873
1044
|
parentPlanId: input.parentPlanId ?? null,
|
|
874
1045
|
};
|
|
875
|
-
|
|
1046
|
+
// The last recommend answer of this invocation, thus `submit_plan`
|
|
1047
|
+
// stamps the snapshot digest, restores a template the model dropped,
|
|
1048
|
+
// and repairs or refuses a claim id the answer did not return.
|
|
1049
|
+
const memory = { steps: new Map(), claims: new Set() };
|
|
1050
|
+
const innerTools = buildInnerTools(holder, trace, persistCtx, deps.pool, deps.resourcePolicy, logger, memory);
|
|
876
1051
|
// Built here rather than at construction: a `describeCall` hook reads no
|
|
877
1052
|
// dep, thus the tool must stay constructible from an empty bag. The tool
|
|
878
1053
|
// definitions are identical across invocations, thus the request prefix
|
|
879
1054
|
// that the cache keys on does not move.
|
|
880
|
-
|
|
1055
|
+
// The guard bounds the repeated calls of one plan: the third call with
|
|
1056
|
+
// an input the plan already sent, or the call past the budget of one
|
|
1057
|
+
// tool, answers a refusal that says to continue. The terminal tools
|
|
1058
|
+
// stay outside it, because a refused submit would strand the plan.
|
|
1059
|
+
let refusals = 0;
|
|
1060
|
+
const searchTools = guardRepeatedCalls(buildPlannerSearchTools(deps, {
|
|
1061
|
+
onRecommend: (answer) => rememberAnswer(memory, answer),
|
|
1062
|
+
}), {
|
|
1063
|
+
onRefusal: (refusal) => {
|
|
1064
|
+
refusals += 1;
|
|
1065
|
+
logger.warn("planner call refused by the guard", { ...refusal, refusals });
|
|
1066
|
+
},
|
|
1067
|
+
});
|
|
881
1068
|
const readTools = deps.toolOutputStore ? [createReadToolOutputTool(deps.toolOutputStore)] : [];
|
|
882
1069
|
const planner = {
|
|
883
1070
|
id: PLANNER_AGENT_ID,
|
|
@@ -901,6 +1088,10 @@ export function createGeneratePlanTool(deps) {
|
|
|
901
1088
|
emit: ctx.emit,
|
|
902
1089
|
runStep: passthroughStep,
|
|
903
1090
|
resolved: () => holder.outcome !== null,
|
|
1091
|
+
// A planner the guard refused too many times is looping:
|
|
1092
|
+
// the early cap ends the search, and the salvage turn
|
|
1093
|
+
// submits the plan it has.
|
|
1094
|
+
stopWhen: () => refusals >= PLANNER_REFUSAL_LIMIT,
|
|
904
1095
|
// No forced tool choice: some Claude models reject it with a
|
|
905
1096
|
// 400. The prompt names the terminal tools, and the salvage
|
|
906
1097
|
// turn below recovers a run that ends on prose.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guarded-address classifier of the web research tools.
|
|
3
|
+
*
|
|
4
|
+
* One pure function over one address: `isGuardedAddress` answers whether the
|
|
5
|
+
* address falls in a range that `web_fetch` asks the user about before it
|
|
6
|
+
* connects. The ranges are a pinned subset of the special-purpose address
|
|
7
|
+
* ranges of the IANA registries — loopback, private, shared, link-local,
|
|
8
|
+
* multicast, reserved, documentation, and benchmarking space, IPv4 and IPv6.
|
|
9
|
+
* A public unicast address is never guarded.
|
|
10
|
+
*
|
|
11
|
+
* The caller owns the question "which address does the connection use": the
|
|
12
|
+
* classifier never resolves a name and it never reads the network.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Is this address one the web tools guard? An IP literal of either family
|
|
16
|
+
* classifies against the pinned table; the IPv4-mapped IPv6 form
|
|
17
|
+
* (`::ffff:10.0.0.1`) classifies as its IPv4 half. A value that is not an IP
|
|
18
|
+
* address gives false — the caller resolves names before it reaches here.
|
|
19
|
+
*/
|
|
20
|
+
export declare function isGuardedAddress(ip: string): boolean;
|