@inflexa-ai/harness 0.45.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.
Files changed (122) hide show
  1. package/dist/agents/conversation-agent.d.ts +10 -0
  2. package/dist/agents/conversation-agent.js +19 -1
  3. package/dist/agents/sandbox/shared.d.ts +23 -2
  4. package/dist/agents/sandbox/shared.js +51 -18
  5. package/dist/agents/sandbox/translational-safety-agent.js +26 -0
  6. package/dist/agents/sandbox/types.d.ts +24 -1
  7. package/dist/agents/sandbox-catalog.d.ts +14 -0
  8. package/dist/agents/sandbox-catalog.js +19 -0
  9. package/dist/app/approve-plan.d.ts +5 -2
  10. package/dist/app/approve-plan.js +1 -1
  11. package/dist/app/chat-turn.js +6 -3
  12. package/dist/config/environment-stores.d.ts +4 -2
  13. package/dist/contracts/chat-events.d.ts +12 -2
  14. package/dist/contracts/chat-events.js +1 -1
  15. package/dist/contracts/chat-frame.js +6 -2
  16. package/dist/contracts/index.d.ts +1 -1
  17. package/dist/contracts/schemas/chat-events.d.ts +25 -0
  18. package/dist/contracts/schemas/chat-events.js +7 -0
  19. package/dist/contracts/schemas/chat-parts.d.ts +39 -0
  20. package/dist/contracts/schemas/chat-parts.js +17 -0
  21. package/dist/contracts/schemas/index.d.ts +1 -1
  22. package/dist/contracts/schemas/index.js +1 -1
  23. package/dist/execution/dbos-workflow-purger.d.ts +2 -0
  24. package/dist/execution/dbos-workflow-purger.js +21 -14
  25. package/dist/execution/run-canceler.d.ts +33 -11
  26. package/dist/execution/run-canceler.js +129 -27
  27. package/dist/execution/run-end-record.d.ts +30 -0
  28. package/dist/execution/run-end-record.js +27 -0
  29. package/dist/index.d.ts +9 -5
  30. package/dist/index.js +11 -1
  31. package/dist/loop/call-guard.d.ts +50 -0
  32. package/dist/loop/call-guard.js +90 -0
  33. package/dist/loop/compaction.d.ts +5 -0
  34. package/dist/loop/compaction.js +17 -0
  35. package/dist/loop/run-agent.d.ts +9 -0
  36. package/dist/loop/run-agent.js +15 -1
  37. package/dist/loop/run-to-terminal.js +4 -1
  38. package/dist/loop/types.d.ts +6 -0
  39. package/dist/memory/conversation-display-recorder.d.ts +0 -2
  40. package/dist/memory/conversation-display-recorder.js +28 -3
  41. package/dist/memory/thread-history.d.ts +34 -7
  42. package/dist/memory/thread-history.js +151 -42
  43. package/dist/prompts/briefing.d.ts +57 -3
  44. package/dist/prompts/briefing.js +129 -2
  45. package/dist/prompts/planner.js +13 -2
  46. package/dist/provenance/seam.d.ts +23 -15
  47. package/dist/provenance/seam.js +7 -5
  48. package/dist/providers/ai-sdk.js +87 -7
  49. package/dist/providers/types.d.ts +6 -0
  50. package/dist/reference-data/catalog.js +141 -2
  51. package/dist/report-model/authoring-grammar.d.ts +26 -26
  52. package/dist/sandbox/image-packages.d.ts +26 -4
  53. package/dist/sandbox/image-packages.js +18 -4
  54. package/dist/schemas/data-profile-schemas.d.ts +12 -12
  55. package/dist/schemas/plan-schemas.d.ts +57 -3
  56. package/dist/schemas/plan-schemas.js +1 -1
  57. package/dist/schemas/workflow-state.d.ts +113 -0
  58. package/dist/schemas/workflow-state.js +54 -0
  59. package/dist/state/artifacts.d.ts +17 -1
  60. package/dist/state/artifacts.js +21 -3
  61. package/dist/state/index.d.ts +2 -2
  62. package/dist/state/index.js +1 -1
  63. package/dist/state/runs.d.ts +43 -9
  64. package/dist/state/runs.js +85 -14
  65. package/dist/state/step-executions.d.ts +9 -0
  66. package/dist/state/step-executions.js +22 -0
  67. package/dist/tools/ad-hoc-router.d.ts +2 -0
  68. package/dist/tools/ad-hoc-router.js +18 -2
  69. package/dist/tools/capability-data.d.ts +36 -0
  70. package/dist/tools/capability-data.js +73 -0
  71. package/dist/tools/execute-analysis.d.ts +37 -9
  72. package/dist/tools/execute-analysis.js +131 -21
  73. package/dist/tools/knowledge/check.d.ts +60 -0
  74. package/dist/tools/knowledge/check.js +92 -0
  75. package/dist/tools/knowledge/client.d.ts +382 -0
  76. package/dist/tools/knowledge/client.js +302 -0
  77. package/dist/tools/knowledge/environment.d.ts +78 -0
  78. package/dist/tools/knowledge/environment.js +105 -0
  79. package/dist/tools/knowledge/index.d.ts +49 -0
  80. package/dist/tools/knowledge/index.js +70 -0
  81. package/dist/tools/knowledge/local-slots.d.ts +62 -0
  82. package/dist/tools/knowledge/local-slots.js +177 -0
  83. package/dist/tools/knowledge/pin.d.ts +14 -0
  84. package/dist/tools/knowledge/pin.js +17 -0
  85. package/dist/tools/knowledge/recommend.d.ts +92 -0
  86. package/dist/tools/knowledge/recommend.js +98 -0
  87. package/dist/tools/knowledge/situation.d.ts +143 -0
  88. package/dist/tools/knowledge/situation.js +164 -0
  89. package/dist/tools/knowledge/skeleton.d.ts +69 -0
  90. package/dist/tools/knowledge/skeleton.js +202 -0
  91. package/dist/tools/knowledge/template.d.ts +137 -0
  92. package/dist/tools/knowledge/template.js +267 -0
  93. package/dist/tools/research/generate-plan.d.ts +23 -0
  94. package/dist/tools/research/generate-plan.js +203 -12
  95. package/dist/tools/research/guarded-address.d.ts +20 -0
  96. package/dist/tools/research/guarded-address.js +148 -0
  97. package/dist/tools/research/searxng-engine.d.ts +40 -0
  98. package/dist/tools/research/searxng-engine.js +120 -0
  99. package/dist/tools/research/web-fetch.d.ts +124 -0
  100. package/dist/tools/research/web-fetch.js +627 -0
  101. package/dist/tools/research/web-search.d.ts +75 -0
  102. package/dist/tools/research/web-search.js +112 -0
  103. package/dist/tools/sandbox/list-available-packages.d.ts +7 -4
  104. package/dist/tools/sandbox/list-available-packages.js +12 -4
  105. package/dist/tools/sandbox/list-available-refs.d.ts +21 -0
  106. package/dist/tools/sandbox/list-available-refs.js +60 -3
  107. package/dist/tools/sandbox/report-blocker.js +5 -1
  108. package/dist/tools/terminal-text.d.ts +11 -0
  109. package/dist/tools/terminal-text.js +19 -0
  110. package/dist/tools/workspace/decision-record.d.ts +81 -0
  111. package/dist/tools/workspace/decision-record.js +117 -0
  112. package/dist/tools/workspace/edit-file.d.ts +3 -1
  113. package/dist/tools/workspace/edit-file.js +20 -2
  114. package/dist/tools/workspace/execute-command.d.ts +17 -0
  115. package/dist/tools/workspace/execute-command.js +28 -1
  116. package/dist/tools/workspace/index.d.ts +1 -0
  117. package/dist/tools/workspace/index.js +1 -0
  118. package/dist/tools/workspace/mutator.d.ts +1 -1
  119. package/dist/workflows/execute-analysis.d.ts +58 -18
  120. package/dist/workflows/execute-analysis.js +273 -58
  121. package/dist/workflows/sandbox-step.d.ts +12 -0
  122. package/package.json +5 -1
@@ -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
- const parsed = PlannerPlanSchema.safeParse(candidate);
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, candidate) };
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 result = fullyValidate(input.plan, resourcePolicy);
379
- if (!result.valid) {
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, result.issues);
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: result.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
- const innerTools = buildInnerTools(holder, trace, persistCtx, deps.pool, deps.resourcePolicy, logger);
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
- const searchTools = buildPlannerSearchTools(deps);
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;
@@ -0,0 +1,148 @@
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
+ import { isIP } from "node:net";
15
+ /** Parse a dotted-quad IPv4 address into its 32-bit value. Anything else gives null. */
16
+ function ipv4ToNumber(ip) {
17
+ const parts = ip.split(".");
18
+ if (parts.length !== 4)
19
+ return null;
20
+ let value = 0;
21
+ for (const part of parts) {
22
+ if (!/^\d{1,3}$/.test(part))
23
+ return null;
24
+ const octet = Number(part);
25
+ if (octet > 255)
26
+ return null;
27
+ value = (value << 8) | octet;
28
+ }
29
+ return value >>> 0;
30
+ }
31
+ /** Parse an IPv6 address into its 128-bit value. Anything else gives null. */
32
+ function ipv6ToBigInt(ip) {
33
+ let text = ip.toLowerCase();
34
+ // An embedded IPv4 tail stands for its two low-order groups.
35
+ if (text.includes(".")) {
36
+ const lastColon = text.lastIndexOf(":");
37
+ const tail = ipv4ToNumber(text.slice(lastColon + 1));
38
+ if (tail === null)
39
+ return null;
40
+ const high = ((tail >>> 16) & 0xffff).toString(16);
41
+ const low = (tail & 0xffff).toString(16);
42
+ text = `${text.slice(0, lastColon + 1)}${high}:${low}`;
43
+ }
44
+ const groupsOf = (half) => {
45
+ if (half === "")
46
+ return [];
47
+ const groups = [];
48
+ for (const group of half.split(":")) {
49
+ if (!/^[0-9a-f]{1,4}$/.test(group))
50
+ return null;
51
+ groups.push(BigInt(`0x${group}`));
52
+ }
53
+ return groups;
54
+ };
55
+ const halves = text.split("::");
56
+ if (halves.length > 2)
57
+ return null;
58
+ const left = groupsOf(halves[0] ?? "");
59
+ const right = halves.length === 2 ? groupsOf(halves[1] ?? "") : [];
60
+ if (left === null || right === null)
61
+ return null;
62
+ const fill = 8 - left.length - right.length;
63
+ if (halves.length === 2 ? fill < 0 : fill !== 0)
64
+ return null;
65
+ return [...left, ...new Array(Math.max(fill, 0)).fill(0n), ...right].reduce((acc, group) => (acc << 16n) | group, 0n);
66
+ }
67
+ const ipv4Span = (first, last) => {
68
+ const low = ipv4ToNumber(first);
69
+ const high = ipv4ToNumber(last);
70
+ if (low === null || high === null)
71
+ throw new Error(`Bad IPv4 range literal: ${first}..${last}`);
72
+ return { first: low, last: high };
73
+ };
74
+ const ipv6Span = (first, last) => {
75
+ const low = ipv6ToBigInt(first);
76
+ const high = ipv6ToBigInt(last);
77
+ if (low === null || high === null)
78
+ throw new Error(`Bad IPv6 range literal: ${first}..${last}`);
79
+ return { first: low, last: high };
80
+ };
81
+ /**
82
+ * The pinned IPv4 table. `240.0.0.0/4` holds the reserved and the broadcast
83
+ * space together, and it ends at the top of the address family.
84
+ */
85
+ const GUARDED_IPV4_RANGES = [
86
+ ipv4Span("0.0.0.0", "0.255.255.255"), // 0.0.0.0/8
87
+ ipv4Span("10.0.0.0", "10.255.255.255"), // 10.0.0.0/8
88
+ ipv4Span("100.64.0.0", "100.127.255.255"), // 100.64.0.0/10
89
+ ipv4Span("127.0.0.0", "127.255.255.255"), // 127.0.0.0/8
90
+ ipv4Span("169.254.0.0", "169.254.255.255"), // 169.254.0.0/16
91
+ ipv4Span("172.16.0.0", "172.31.255.255"), // 172.16.0.0/12
92
+ ipv4Span("192.0.0.0", "192.0.0.255"), // 192.0.0.0/24
93
+ ipv4Span("192.0.2.0", "192.0.2.255"), // 192.0.2.0/24
94
+ ipv4Span("192.88.99.0", "192.88.99.255"), // 192.88.99.0/24
95
+ ipv4Span("192.168.0.0", "192.168.255.255"), // 192.168.0.0/16
96
+ ipv4Span("198.18.0.0", "198.19.255.255"), // 198.18.0.0/15
97
+ ipv4Span("198.51.100.0", "198.51.100.255"), // 198.51.100.0/24
98
+ ipv4Span("203.0.113.0", "203.0.113.255"), // 203.0.113.0/24
99
+ ipv4Span("224.0.0.0", "239.255.255.255"), // 224.0.0.0/4
100
+ ipv4Span("240.0.0.0", "255.255.255.255"), // 240.0.0.0/4
101
+ ];
102
+ /**
103
+ * The pinned IPv6 table, without `::ffff:0:0/96`: an IPv4-mapped address
104
+ * normalizes to its IPv4 half first, thus that range classifies through the
105
+ * IPv4 table above.
106
+ */
107
+ const GUARDED_IPV6_RANGES = [
108
+ ipv6Span("::", "::"), // ::/128
109
+ ipv6Span("::1", "::1"), // ::1/128
110
+ ipv6Span("64:ff9b::", "64:ff9b::ffff:ffff"), // 64:ff9b::/96
111
+ ipv6Span("64:ff9b:1::", "64:ff9b:1:ffff:ffff:ffff:ffff:ffff"), // 64:ff9b:1::/48
112
+ ipv6Span("100::", "100::ffff:ffff:ffff:ffff"), // 100::/64
113
+ ipv6Span("2001::", "2001:0:ffff:ffff:ffff:ffff:ffff:ffff"), // 2001::/32
114
+ ipv6Span("2001:db8::", "2001:db8:ffff:ffff:ffff:ffff:ffff:ffff"), // 2001:db8::/32
115
+ ipv6Span("2002::", "2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff"), // 2002::/16
116
+ ipv6Span("fc00::", "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"), // fc00::/7
117
+ ipv6Span("fe80::", "febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff"), // fe80::/10
118
+ ipv6Span("ff00::", "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"), // ff00::/8
119
+ ];
120
+ function isGuardedIpv4(value) {
121
+ return GUARDED_IPV4_RANGES.some((range) => value >= range.first && value <= range.last);
122
+ }
123
+ function isGuardedIpv6(value) {
124
+ return GUARDED_IPV6_RANGES.some((range) => value >= range.first && value <= range.last);
125
+ }
126
+ /**
127
+ * Is this address one the web tools guard? An IP literal of either family
128
+ * classifies against the pinned table; the IPv4-mapped IPv6 form
129
+ * (`::ffff:10.0.0.1`) classifies as its IPv4 half. A value that is not an IP
130
+ * address gives false — the caller resolves names before it reaches here.
131
+ */
132
+ export function isGuardedAddress(ip) {
133
+ const family = isIP(ip);
134
+ if (family === 4) {
135
+ const value = ipv4ToNumber(ip);
136
+ return value !== null && isGuardedIpv4(value);
137
+ }
138
+ if (family === 6) {
139
+ const value = ipv6ToBigInt(ip);
140
+ if (value === null)
141
+ return false;
142
+ // The IPv4-mapped form, ::ffff:0:0/96, classifies as its IPv4 half.
143
+ if (value >> 32n === 0xffffn)
144
+ return isGuardedIpv4(Number(value & 0xffffffffn));
145
+ return isGuardedIpv6(value);
146
+ }
147
+ return false;
148
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The SearXNG realization of the `WebSearchEngine` capability.
3
+ *
4
+ * One SearXNG instance is the only engine the harness ships (the embedder owns
5
+ * it and builds this realization at its composition root). The call rides
6
+ * `apiFetchValidated` with a zod schema over the JSON envelope, and every
7
+ * failure lands on the error side of the `Result` as the data-status variant the
8
+ * tool reports — an HTTP error, a spent retryable status, a schema mismatch,
9
+ * and a body that is not JSON are answers, not errors of the turn.
10
+ */
11
+ import { z } from "zod";
12
+ import { type ApiError } from "../lib/api-utils.js";
13
+ import type { WebSearchEngine, WebSearchEngineError, WebSearchPage } from "./web-search.js";
14
+ export declare const SearxngSearchResponseSchema: z.ZodObject<{
15
+ results: z.ZodOptional<z.ZodArray<z.ZodObject<{
16
+ title: z.ZodOptional<z.ZodNullable<z.ZodString>>;
17
+ url: z.ZodOptional<z.ZodNullable<z.ZodString>>;
18
+ content: z.ZodOptional<z.ZodNullable<z.ZodString>>;
19
+ }, z.core.$strip>>>;
20
+ answers: z.ZodOptional<z.ZodArray<z.ZodObject<{
21
+ answer: z.ZodOptional<z.ZodNullable<z.ZodString>>;
22
+ url: z.ZodOptional<z.ZodNullable<z.ZodString>>;
23
+ }, z.core.$loose>>>;
24
+ }, z.core.$strip>;
25
+ /** Map a parsed SearXNG envelope to the rows the tool reports. */
26
+ export declare function mapSearxngResponse(response: z.infer<typeof SearxngSearchResponseSchema>): WebSearchPage;
27
+ /**
28
+ * Normalize the SearXNG base URL the embedder gives: `http`/`https` only, no
29
+ * user name and no password, and no query, fragment, or trailing slash. The
30
+ * value is a composition-root constant, thus a bad one throws here at wiring
31
+ * time and not inside a tool call.
32
+ */
33
+ export declare function normalizeSearxngBaseUrl(raw: string): string;
34
+ /** Map one `ApiError` of the helper to the error data variant it names. */
35
+ export declare function describeSearchFailure(error: ApiError): WebSearchEngineError;
36
+ export interface SearxngEngineDeps {
37
+ /** The base URL of the SearXNG instance the embedder runs. */
38
+ readonly baseUrl: string;
39
+ }
40
+ export declare function createSearxngEngine(deps: SearxngEngineDeps): WebSearchEngine;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The SearXNG realization of the `WebSearchEngine` capability.
3
+ *
4
+ * One SearXNG instance is the only engine the harness ships (the embedder owns
5
+ * it and builds this realization at its composition root). The call rides
6
+ * `apiFetchValidated` with a zod schema over the JSON envelope, and every
7
+ * failure lands on the error side of the `Result` as the data-status variant the
8
+ * tool reports — an HTTP error, a spent retryable status, a schema mismatch,
9
+ * and a body that is not JSON are answers, not errors of the turn.
10
+ */
11
+ import { err, ok } from "neverthrow";
12
+ import { z } from "zod";
13
+ import { apiFetchValidated } from "../lib/api-utils.js";
14
+ // SearXNG sends an explicit null for a snippet no engine produced, and it omits
15
+ // a key no engine filled, thus each field is optional and nullable alike. The
16
+ // fields an engine adds (`engine`, `score`, `publishedDate`, …) pass and strip.
17
+ const SearxngResultSchema = z.object({
18
+ title: z.string().nullable().optional(),
19
+ url: z.string().nullable().optional(),
20
+ content: z.string().nullable().optional(),
21
+ });
22
+ // An answer is an object of a changing shape (`answer`, `url`, `template`,
23
+ // `engine`, …), thus the schema keeps it loose and the mapper reads the two
24
+ // fields the tool reports.
25
+ export const SearxngSearchResponseSchema = z.object({
26
+ results: z.array(SearxngResultSchema).optional(),
27
+ answers: z.array(z.looseObject({ answer: z.string().nullable().optional(), url: z.string().nullable().optional() })).optional(),
28
+ });
29
+ /** Map a parsed SearXNG envelope to the rows the tool reports. */
30
+ export function mapSearxngResponse(response) {
31
+ const answers = [];
32
+ for (const row of response.answers ?? []) {
33
+ const answer = typeof row.answer === "string" ? row.answer : undefined;
34
+ const url = typeof row.url === "string" ? row.url : undefined;
35
+ if (answer === undefined && url === undefined)
36
+ continue;
37
+ answers.push({ answer, url });
38
+ }
39
+ const results = [];
40
+ for (const row of response.results ?? []) {
41
+ if (typeof row.url !== "string" || typeof row.title !== "string")
42
+ continue;
43
+ results.push({ title: row.title, url: row.url, snippet: typeof row.content === "string" ? row.content : undefined });
44
+ }
45
+ return { answers, results };
46
+ }
47
+ /**
48
+ * Normalize the SearXNG base URL the embedder gives: `http`/`https` only, no
49
+ * user name and no password, and no query, fragment, or trailing slash. The
50
+ * value is a composition-root constant, thus a bad one throws here at wiring
51
+ * time and not inside a tool call.
52
+ */
53
+ export function normalizeSearxngBaseUrl(raw) {
54
+ const parsed = new URL(raw);
55
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
56
+ throw new Error(`The SearXNG base URL must use http or https: ${raw}`);
57
+ }
58
+ if (parsed.username !== "" || parsed.password !== "") {
59
+ throw new Error(`The SearXNG base URL must carry no user name or password: ${raw}`);
60
+ }
61
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/+$/, "")}`;
62
+ }
63
+ /** The first 300 characters of a body, as the HTTP-error text reports it. */
64
+ function bodyExcerpt(body) {
65
+ return body.length > 300 ? body.slice(0, 300) : body;
66
+ }
67
+ /** Map one `ApiError` of the helper to the error data variant it names. */
68
+ export function describeSearchFailure(error) {
69
+ switch (error.type) {
70
+ case "http_status":
71
+ return {
72
+ status: "http_error",
73
+ httpStatus: error.status,
74
+ text: `SearXNG answered HTTP ${error.status}${error.body ? `: ${bodyExcerpt(error.body)}` : ""}`,
75
+ };
76
+ case "invalid_response":
77
+ return { status: "schema_mismatch", text: `SearXNG response did not match the expected schema: ${error.issues}` };
78
+ case "timeout":
79
+ return {
80
+ status: "not_json",
81
+ text: `SearXNG did not answer with a valid JSON body: the request timed out after ${error.timeoutMs}ms`,
82
+ };
83
+ case "exhausted": {
84
+ // A spent retryable status arrives as `exhausted` with an `HTTP <status>`
85
+ // last error, because the helper reports it there and not as `http_status`.
86
+ const status = /^HTTP (\d+)$/.exec(error.lastError);
87
+ if (status) {
88
+ return {
89
+ status: "retryable_status",
90
+ httpStatus: Number(status[1]),
91
+ text: `SearXNG answered HTTP ${status[1]} and spent its retry attempt`,
92
+ };
93
+ }
94
+ // A transport failure and a body that will not parse share this bucket,
95
+ // and the text stays fixed so it never echoes a runtime parse message.
96
+ return {
97
+ status: "not_json",
98
+ text: "SearXNG did not answer with a valid JSON body",
99
+ detail: error.lastError,
100
+ };
101
+ }
102
+ }
103
+ }
104
+ export function createSearxngEngine(deps) {
105
+ const baseUrl = normalizeSearxngBaseUrl(deps.baseUrl);
106
+ return {
107
+ async search({ query, recencyFilter }) {
108
+ const params = new URLSearchParams({ q: query, format: "json" });
109
+ if (recencyFilter !== undefined)
110
+ params.set("time_range", recencyFilter);
111
+ const response = await apiFetchValidated(`${baseUrl}/search?${params}`, SearxngSearchResponseSchema, {
112
+ headers: { Accept: "application/json" },
113
+ maxRetries: 0,
114
+ });
115
+ if (response.isErr())
116
+ return err(describeSearchFailure(response.error));
117
+ return ok(mapSearxngResponse(response.value));
118
+ },
119
+ };
120
+ }