Files
learnharness/src/agent/subagents/researcher.ts
T
amosblomqvist b87a6925af Wave A: pi-native tool layer — delete LhTool/toPiTool, TypeBox everywhere, terminate on questions
- src/agent/lh-tool.ts deleted: the zod-wrapped LhTool + toPiTool bridge
  (z.toJSONSchema per call, clientQuestion flag, hand-rolled result mapping)
  is gone. Tools are pi-agent-core native AgentTools now.
- New src/agent/tool-execute.ts owns what the wrapper added: the abort guard
  (runWithAbortGuard: run signal + 30s per-call timeout + 250ms grace,
  'Operation aborted' / timeout wording preserved), ToolInputError (the
  retryable '- problem at path' format), result mapping (string verbatim /
  object JSON / image parts, details rides the record's tool part output),
  and questionResult() — placeholder + terminate:true (owner Option A:
  belt of terminate + slim makeQuestionStopper with the clean-question
  rule; maxTurns deleted from the teaching turn entirely per owner).
- All 11 tool files + tools.ts registries converted to TypeBox parameters
  (typebox@1.3.7, pinned to pi-agent-core's copy). zod refinements
  (quizInputProblems, askOptionProblems, live-nodeId) moved from schemas
  into execute — the model sees the same retryable error shape.
- question-core + contracts/probe TypeBox-authored (single source shared
  with the UI); tool-parts parse seam runs the boundary's full chain
  (Convert → Check → Clean → refinement), fixing the live-smoke lockout:
  pi's boundary Value.Converts correctAnswer: 17 → '17' but the record
  keeps raw args, so a parse without Convert rejected a posed quiz
  (invisible pending question, turn ended). Pinned with the real smoke
  emission shape.
- Subagents converted: demo-builder/researcher AgentTool-native,
  visual-maker/pdf-reader declaration-only pi-ai Tools with local execute
  fns, extraction submit schemas re-authored in TypeBox (owner option 2:
  zod defaults → Type.Optional + fill at the submit seam).
- Accepted delta, flagged: TypeBox-native schemas skip pi's JSON-Schema
  primitive coercion fast path... (boundary still Value.Converts), and
  prober-quiz unknown keys ride through (Value.Clean strips at display).
- Tests: 988 passing (was 967 at the recon baseline); tool-abort and
  read-tool rewritten against the native seams; question-core/question-
  tools/tool-parts/demos/web-search/graph-tools/plan-tree/probe-done/
  tool-toggles converted to Value.Check/execute pins.

Live smoke (PORT=3004, real model): native ls turn streams end-to-end; a
real quiz pose (first attempt missing correctAnswer → tool-input-error →
model retry → posed) terminates the turn cleanly and persists pending.
2026-09-25 23:32:13 +03:00

96 lines
4.0 KiB
TypeScript

import type { AgentTool } from "@earendil-works/pi-agent-core";
import type { Usage } from "@earendil-works/pi-ai";
import { createWebTools } from "../tools/web.ts";
import type { SearchProvider } from "../../core/search.ts";
import type { PiEngine } from "../models.ts";
import { runSubagent } from "./pi-run.ts";
/**
* The researcher sub-agent (vision §2.5): a SEPARATE ToolLoopAgent instance
* from the teacher — an isolated loop with only web_search + web_fetch, so
* raw search/fetch churn never touches the teacher's context window.
*
* System prompt adapted from the pi researcher agent
* (~/.pi/agent/extensions/interactive-subagents/agents/researcher.md).
* The engine is injectable so tests run the loop with a scripted faux engine.
*/
const INSTRUCTIONS = `You are a research specialist. You work for a teacher agent inside a learning
application: given a research question, conduct thorough web research and produce a focused,
well-sourced brief that the teacher can teach from.
You operate in an isolated context with no knowledge of the lesson or any prior conversation.
All necessary context is in the task description.
Process:
1. Break the question into 2-4 searchable facets.
2. Search with web_search using varied angles:
- the direct answer query (the obvious one)
- an authoritative-source query (official docs, specs, primary sources)
- a practical-experience query (case studies, benchmarks, real-world usage)
- a recent-developments query (only if the topic is time-sensitive)
3. Read the results. Identify what's well-covered and what has gaps. If the first round
doesn't fully answer the question, search again with refined queries targeting the gaps.
4. For the 2-3 most promising source URLs, use web_fetch to get the full page content.
5. Synthesize everything into a brief that directly answers the question.
Evaluation — what to keep vs drop:
- Official docs and primary sources outweigh blog posts and forum threads.
- Recent sources outweigh stale ones.
- Sources that directly address the question outweigh tangentially related ones.
- Drop SEO filler, outdated info, and beginner tutorials (unless that's the audience).
Tool outputs may be error strings (e.g. search unconfigured, a page that failed to fetch).
Adapt: try another URL or another search angle; if research is genuinely impossible, say so
in the brief instead of inventing facts.
Your FINAL assistant message is your entire deliverable — it must stand alone, using this format:
## Summary
2-3 sentence direct answer.
## Findings
Numbered findings with inline source citations:
1. **Finding** — explanation. [Source](url)
## Sources
- Kept: Source Title (url) — why relevant
- Dropped: Source Title — why excluded
## Gaps
What couldn't be answered. Suggested next steps.`;
export interface ResearcherRunOptions {
engine?: PiEngine;
/** The search seam (search port wave, 2026-09-20): resolves the LIVE
* provider per call, null = honestly unavailable (the web_search tool
* reports that in its output — no crash, no silent fallback). Absent
* seam reads as unavailable: the server wiring always injects one; the
* dispatch-level drop (jobTools) keeps doomed jobs from even starting. */
search?: () => SearchProvider | null;
abortSignal?: AbortSignal;
onUsage?: (usage: Usage) => void;
onToolStart?: (toolName: string, args: unknown) => void;
}
/**
* Run the researcher to completion: isolated context, web tools only, final
* text = the brief. The engine and search seam are injectable so tests run
* the loop scripted.
*/
export async function runResearcherAgent(prompt: string, opts: ResearcherRunOptions = {}): Promise<string> {
const { text } = await runSubagent(Object.values(createWebTools(opts.search ?? (() => null))), {
role: "researcher",
prompt,
engine: opts.engine,
systemPrompt: INSTRUCTIONS,
maxTurns: 12,
abortSignal: opts.abortSignal,
onUsage: opts.onUsage,
onToolStart: opts.onToolStart,
});
return text;
}
export type ResearcherAgent = ReturnType<typeof runResearcherAgent>;