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.
This commit is contained in:
+16
-14
@@ -1,7 +1,8 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { lhTool } from "../lh-tool.ts";
|
||||
import { Type } from "typebox";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { serverExecute } from "../tool-execute.ts";
|
||||
import {
|
||||
capOutput,
|
||||
formatSize,
|
||||
@@ -23,20 +24,21 @@ import {
|
||||
|
||||
const DEFAULT_LIMIT = 1000;
|
||||
|
||||
const findInputSchema = z.object({
|
||||
pattern: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'"),
|
||||
path: z.string().optional().describe("Directory to search in (default: the learner's home directory)"),
|
||||
limit: z.number().int().positive().optional().describe("Maximum number of results (default: 1000)"),
|
||||
const findInput = Type.Object({
|
||||
pattern: Type.String({
|
||||
minLength: 1,
|
||||
description: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'",
|
||||
}),
|
||||
path: Type.Optional(Type.String({ description: "Directory to search in (default: the learner's home directory)" })),
|
||||
limit: Type.Optional(Type.Integer({ description: "Maximum number of results (default: 1000)" })),
|
||||
});
|
||||
|
||||
export const findTool = lhTool({
|
||||
export const findTool: AgentTool<typeof findInput> = {
|
||||
name: "find",
|
||||
label: "find",
|
||||
description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Ignores node_modules, .git, and Library directories. Output is truncated to ${DEFAULT_LIMIT} results or ${formatSize(MAX_OUTPUT_BYTES)} (whichever is hit first).`,
|
||||
schema: findInputSchema,
|
||||
async execute(input, ctx) {
|
||||
parameters: findInput,
|
||||
execute: serverExecute("find", findInput, async (input, ctx) => {
|
||||
if (ctx?.signal?.aborted) return "Operation aborted";
|
||||
const searchPath = resolveSourcePath(input.path);
|
||||
let stat;
|
||||
@@ -81,5 +83,5 @@ export const findTool = lhTool({
|
||||
if (truncated) notices.push(`${formatSize(MAX_OUTPUT_BYTES)} limit reached`);
|
||||
if (timedOut) notices.push("search timed out after 10s — partial results, narrow the directory");
|
||||
return notices.length > 0 ? `${content}\n\n[${notices.join(". ")}]` : content;
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
+25
-18
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { lhTool, type LhTool } from "../lh-tool.ts";
|
||||
import { Type } from "typebox";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { serverExecute } from "../tool-execute.ts";
|
||||
import { graphSessionIndex, graphSessionPropositions } from "../../core/graph.ts";
|
||||
import type { ChatStore, GraphStore } from "../../core/ports.ts";
|
||||
|
||||
@@ -28,17 +29,27 @@ export interface GraphToolContext {
|
||||
graphStore: GraphStore;
|
||||
}
|
||||
|
||||
export function proberGraphTools(ctx: GraphToolContext): Record<string, LhTool<any, any>> {
|
||||
const listSessionsInput = Type.Object({});
|
||||
|
||||
const listPropositionsInput = Type.Object({
|
||||
session: Type.String({
|
||||
minLength: 1,
|
||||
description: "The session's id exactly as list_sessions reported it (a chat id or a notes source id)",
|
||||
}),
|
||||
});
|
||||
|
||||
export function proberGraphTools(ctx: GraphToolContext): Record<string, AgentTool<any>> {
|
||||
const deps = { chatStore: ctx.chatStore, graphStore: ctx.graphStore };
|
||||
return {
|
||||
list_sessions: lhTool({
|
||||
list_sessions: {
|
||||
name: "list_sessions",
|
||||
label: "list_sessions",
|
||||
description:
|
||||
"List the sessions of the learner's persistent knowledge graph — every chat or imported-notes file that ever contributed a proposition, most recent first. The map is organized into birth-session clusters (propositions grouped by the session they were first learned in), and session names are those clusters' labels. " +
|
||||
"Each line: the session's name, kind (chat/file), how many propositions were born in it, its dates, and its id — use that id with list_propositions. " +
|
||||
"Reading this first shows what he has already covered and when, before you probe anything.",
|
||||
schema: z.object({}),
|
||||
execute: async (): Promise<string> => {
|
||||
parameters: listSessionsInput,
|
||||
execute: serverExecute("list_sessions", listSessionsInput, async (): Promise<string> => {
|
||||
const sessions = graphSessionIndex(deps);
|
||||
if (sessions.length === 0) return "The knowledge graph is empty — no session has contributed propositions yet.";
|
||||
const lines = sessions.map((s, i) =>
|
||||
@@ -46,21 +57,17 @@ export function proberGraphTools(ctx: GraphToolContext): Record<string, LhTool<a
|
||||
`born ${s.createdAt ?? "unknown date"}${s.updatedAt ? `, last active ${s.updatedAt}` : ""}) — id: ${s.id}`,
|
||||
);
|
||||
return `${sessions.length} session(s) in the knowledge graph, most recent first:\n${lines.join("\n")}`;
|
||||
},
|
||||
}),
|
||||
list_propositions: lhTool({
|
||||
}),
|
||||
},
|
||||
list_propositions: {
|
||||
name: "list_propositions",
|
||||
label: "list_propositions",
|
||||
description:
|
||||
"List the propositions BORN in one knowledge-graph session — what was first learned there — each with his latest understanding level on the scale solid / shaky / accepted / resisted / not yet engaged, and the date that level was set. " +
|
||||
"Names, levels, and dating only — no statement text, no session contents. " +
|
||||
"The level is that session's own latest evaluation; other sessions may have moved it since, so cross-session levels can differ.",
|
||||
schema: z.object({
|
||||
session: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("The session's id exactly as list_sessions reported it (a chat id or a notes source id)"),
|
||||
}),
|
||||
execute: async ({ session }): Promise<string> => {
|
||||
parameters: listPropositionsInput,
|
||||
execute: serverExecute("list_propositions", listPropositionsInput, async ({ session }): Promise<string> => {
|
||||
const propositions = graphSessionPropositions(deps, session);
|
||||
if (propositions.length === 0) {
|
||||
return `No propositions born in session "${session}". Call list_sessions for the current ids.`;
|
||||
@@ -69,7 +76,7 @@ export function proberGraphTools(ctx: GraphToolContext): Record<string, LhTool<a
|
||||
(p) => `- ${p.name} — ${p.understanding ?? "never evaluated"}${p.evaluatedAt ? ` (as of ${p.evaluatedAt})` : ""}`,
|
||||
);
|
||||
return `${propositions.length} proposition(s) born in this session:\n${lines.join("\n")}`;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+23
-18
@@ -1,7 +1,8 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { lhTool } from "../lh-tool.ts";
|
||||
import { Type } from "typebox";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { serverExecute } from "../tool-execute.ts";
|
||||
import {
|
||||
capOutput,
|
||||
formatSize,
|
||||
@@ -29,24 +30,28 @@ import {
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
const grepInputSchema = z.object({
|
||||
pattern: z.string().min(1).describe("Search pattern (regex or literal string)"),
|
||||
path: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Directory or file to search (default: the learner's home directory)"),
|
||||
glob: z.string().optional().describe("Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'"),
|
||||
ignoreCase: z.boolean().optional().describe("Case-insensitive search (default: false)"),
|
||||
literal: z.boolean().optional().describe("Treat pattern as literal string instead of regex (default: false)"),
|
||||
context: z.number().int().nonnegative().optional().describe("Number of lines to show before and after each match (default: 0)"),
|
||||
limit: z.number().int().positive().optional().describe("Maximum number of matches to return (default: 100)"),
|
||||
const grepInput = Type.Object({
|
||||
pattern: Type.String({ minLength: 1, description: "Search pattern (regex or literal string)" }),
|
||||
path: Type.Optional(
|
||||
Type.String({ description: "Directory or file to search (default: the learner's home directory)" }),
|
||||
),
|
||||
glob: Type.Optional(Type.String({ description: "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'" })),
|
||||
ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
|
||||
literal: Type.Optional(
|
||||
Type.Boolean({ description: "Treat pattern as literal string instead of regex (default: false)" }),
|
||||
),
|
||||
context: Type.Optional(
|
||||
Type.Integer({ minimum: 0, description: "Number of lines to show before and after each match (default: 0)" }),
|
||||
),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum number of matches to return (default: 100)" })),
|
||||
});
|
||||
|
||||
export const grepTool = lhTool({
|
||||
export const grepTool: AgentTool<typeof grepInput> = {
|
||||
name: "grep",
|
||||
label: "grep",
|
||||
description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Ignores node_modules, .git, and Library directories. Output is truncated to ${DEFAULT_LIMIT} matches or ${formatSize(MAX_OUTPUT_BYTES)} (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`,
|
||||
schema: grepInputSchema,
|
||||
async execute(input, ctx) {
|
||||
parameters: grepInput,
|
||||
execute: serverExecute("grep", grepInput, async (input, ctx) => {
|
||||
if (ctx?.signal?.aborted) return "Operation aborted";
|
||||
const searchPath = resolveSourcePath(input.path);
|
||||
|
||||
@@ -169,5 +174,5 @@ export const grepTool = lhTool({
|
||||
if (truncated) notices.push(`${formatSize(MAX_OUTPUT_BYTES)} limit reached`);
|
||||
if (timedOut || scanTimedOut) notices.push("search timed out after 10s — partial results, narrow the directory");
|
||||
return notices.length > 0 ? `${content}\n\n[${notices.join(". ")}]` : content;
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
+55
-48
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { lhTool, type LhTool } from "../lh-tool.ts";
|
||||
import { Type, type Static } from "typebox";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { serverExecute } from "../tool-execute.ts";
|
||||
import type { JobRecord } from "../../core/contracts.ts";
|
||||
import type { JobStore } from "../../core/ports.ts";
|
||||
|
||||
@@ -30,43 +31,41 @@ export interface JobToolContext {
|
||||
searchAvailable?: () => boolean;
|
||||
}
|
||||
|
||||
export const researchInputSchema = z.object({
|
||||
question: z
|
||||
.string()
|
||||
.describe("The single research question to answer. Make it specific — the researcher sees nothing else of the lesson."),
|
||||
context: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Why you need this / what the lesson needs it for — focuses the brief. One or two sentences."),
|
||||
export const researchInputSchema = Type.Object({
|
||||
question: Type.String({
|
||||
description: "The single research question to answer. Make it specific — the researcher sees nothing else of the lesson.",
|
||||
}),
|
||||
context: Type.Optional(
|
||||
Type.String({
|
||||
description: "Why you need this / what the lesson needs it for — focuses the brief. One or two sentences.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const demoInputSchema = z.object({
|
||||
title: z.string().describe("Short title for the demo card, e.g. 'Damped harmonic oscillator'."),
|
||||
brief: z
|
||||
.string()
|
||||
.describe(
|
||||
export const demoInputSchema = Type.Object({
|
||||
title: Type.String({ description: "Short title for the demo card, e.g. 'Damped harmonic oscillator'." }),
|
||||
brief: Type.String({
|
||||
description:
|
||||
"Precise brief for the builder: the concept, the parameters/controls and what each does, the interactions, and the visual style. " +
|
||||
"The builder sees NOTHING else of the lesson — include everything it needs, including necessary formulas and constants.",
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
export const visualInputSchema = z.object({
|
||||
brief: z
|
||||
.string()
|
||||
.describe(
|
||||
export const visualInputSchema = Type.Object({
|
||||
brief: Type.String({
|
||||
description:
|
||||
"Precise brief for ONE static figure: exactly what it must show — coordinates, labels, proportions, orientation. " +
|
||||
"The maker sees NOTHING else of the lesson; a wrong or vague brief produces a wrong figure.",
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
export const pdfInputSchema = z.object({
|
||||
path: z.string().describe("Path to the learner's PDF (relative or absolute), exactly as they gave it."),
|
||||
question: z
|
||||
.string()
|
||||
.describe(
|
||||
export const pdfInputSchema = Type.Object({
|
||||
path: Type.String({ description: "Path to the learner's PDF (relative or absolute), exactly as they gave it." }),
|
||||
question: Type.String({
|
||||
description:
|
||||
"What you need from the document — the specific content, explanation, or answer to extract and brief. " +
|
||||
"The reader sees NOTHING else of the lesson.",
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -133,39 +132,47 @@ const PDF_DESCRIPTION =
|
||||
* server can't dispatch doomed research jobs. Jobs ALREADY in flight when
|
||||
* a key disappears still run to their honest failure (runner backstop).
|
||||
*/
|
||||
export function jobTools(ctx: JobToolContext): Record<string, LhTool<any, any>> {
|
||||
export function jobTools(ctx: JobToolContext): Record<string, AgentTool<any>> {
|
||||
const searchOn = ctx.searchAvailable?.() ?? true;
|
||||
return {
|
||||
...(searchOn && {
|
||||
research: lhTool({
|
||||
research: {
|
||||
name: "research",
|
||||
label: "research",
|
||||
description: RESEARCH_DESCRIPTION,
|
||||
schema: researchInputSchema,
|
||||
execute: async (input, { toolCallId }) => dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input),
|
||||
}),
|
||||
parameters: researchInputSchema,
|
||||
execute: serverExecute("research", researchInputSchema, (input, { toolCallId }) =>
|
||||
dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input),
|
||||
),
|
||||
},
|
||||
}),
|
||||
demo: lhTool({
|
||||
demo: {
|
||||
name: "demo",
|
||||
label: "demo",
|
||||
description: DEMO_DESCRIPTION,
|
||||
schema: demoInputSchema,
|
||||
execute: async (input, { toolCallId }) => dispatch(ctx, "demo", toolCallId, DEMO_ACK, input),
|
||||
}),
|
||||
visual: lhTool({
|
||||
parameters: demoInputSchema,
|
||||
execute: serverExecute("demo", demoInputSchema, (input, { toolCallId }) => dispatch(ctx, "demo", toolCallId, DEMO_ACK, input)),
|
||||
},
|
||||
visual: {
|
||||
name: "visual",
|
||||
label: "visual",
|
||||
description: VISUAL_DESCRIPTION,
|
||||
schema: visualInputSchema,
|
||||
execute: async (input, { toolCallId }) => dispatch(ctx, "visual", toolCallId, VISUAL_ACK, input),
|
||||
}),
|
||||
pdf: lhTool({
|
||||
parameters: visualInputSchema,
|
||||
execute: serverExecute("visual", visualInputSchema, (input, { toolCallId }) =>
|
||||
dispatch(ctx, "visual", toolCallId, VISUAL_ACK, input),
|
||||
),
|
||||
},
|
||||
pdf: {
|
||||
name: "pdf",
|
||||
label: "pdf",
|
||||
description: PDF_DESCRIPTION,
|
||||
schema: pdfInputSchema,
|
||||
execute: async (input, { toolCallId }) => dispatch(ctx, "pdf", toolCallId, PDF_ACK, input),
|
||||
}),
|
||||
parameters: pdfInputSchema,
|
||||
execute: serverExecute("pdf", pdfInputSchema, (input, { toolCallId }) => dispatch(ctx, "pdf", toolCallId, PDF_ACK, input)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ResearchInput = z.infer<typeof researchInputSchema>;
|
||||
export type DemoInput = z.infer<typeof demoInputSchema>;
|
||||
export type VisualInput = z.infer<typeof visualInputSchema>;
|
||||
export type PdfInput = z.infer<typeof pdfInputSchema>;
|
||||
export type ResearchInput = Static<typeof researchInputSchema>;
|
||||
export type DemoInput = Static<typeof demoInputSchema>;
|
||||
export type VisualInput = Static<typeof visualInputSchema>;
|
||||
export type PdfInput = Static<typeof pdfInputSchema>;
|
||||
|
||||
+16
-13
@@ -1,7 +1,8 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { lhTool } from "../lh-tool.ts";
|
||||
import { Type } from "typebox";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { serverExecute } from "../tool-execute.ts";
|
||||
import { capOutput, formatSize, MAX_OUTPUT_BYTES, resolveSourcePath } from "./fs-shared.ts";
|
||||
|
||||
/**
|
||||
@@ -14,19 +15,21 @@ import { capOutput, formatSize, MAX_OUTPUT_BYTES, resolveSourcePath } from "./fs
|
||||
|
||||
const DEFAULT_LIMIT = 500;
|
||||
|
||||
const lsInputSchema = z.object({
|
||||
path: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Directory to list (default: the learner's home directory)"),
|
||||
limit: z.number().int().positive().optional().describe("Maximum number of entries to return (default: 500)"),
|
||||
const lsInput = Type.Object({
|
||||
path: Type.Optional(
|
||||
Type.String({ description: "Directory to list (default: the learner's home directory)" }),
|
||||
),
|
||||
limit: Type.Optional(
|
||||
Type.Integer({ description: "Maximum number of entries to return (default: 500)" }),
|
||||
),
|
||||
});
|
||||
|
||||
export const lsTool = lhTool({
|
||||
export const lsTool: AgentTool<typeof lsInput> = {
|
||||
name: "ls",
|
||||
label: "ls",
|
||||
description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to ${DEFAULT_LIMIT} entries or ${formatSize(MAX_OUTPUT_BYTES)} (whichever is hit first).`,
|
||||
schema: lsInputSchema,
|
||||
async execute(input) {
|
||||
parameters: lsInput,
|
||||
execute: serverExecute("ls", lsInput, async (input) => {
|
||||
const dirPath = resolveSourcePath(input.path);
|
||||
|
||||
let stat;
|
||||
@@ -70,5 +73,5 @@ export const lsTool = lhTool({
|
||||
if (entryLimitReached) notices.push(`${effectiveLimit} entries limit`);
|
||||
if (truncated) notices.push(`${formatSize(MAX_OUTPUT_BYTES)} limit`);
|
||||
return notices.length > 0 ? `${content}\n\n[Truncated: ${notices.join(", ")}]` : content;
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
+20
-17
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { lhTool, type LhTool } from "../lh-tool.ts";
|
||||
import { Type } from "typebox";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { serverExecute } from "../tool-execute.ts";
|
||||
import type { PlanToolResult, PlanTree } from "../../core/contracts.ts";
|
||||
import type { ChatStore } from "../../core/ports.ts";
|
||||
import { createTree, emptyTree, PlanTreeError } from "../../core/plan-tree.ts";
|
||||
@@ -37,27 +38,29 @@ export interface PlanToolContext {
|
||||
* latest state straight from the transcript's tool outputs (the plan
|
||||
* window and the plan blocks).
|
||||
*/
|
||||
export function planTools(ctx: PlanToolContext): Record<string, LhTool<any, any>> {
|
||||
const planInitInput = Type.Object({
|
||||
mermaid: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
"A mermaid graph block (any direction), e.g.:\ngraph TD\n primitives[Number primitives] --> operators[Arithmetic operators] --> expressions[Evaluating expressions]\n" +
|
||||
"The only strict rule: ids are permanent lowercase slugs (a-z, 0-9, hyphens/underscores) — checks reference them via nodeId. " +
|
||||
"Titles are free single-line text; `-->` means 'from = prerequisite' (chains fine); no subgraphs, styles, or classes.",
|
||||
}),
|
||||
});
|
||||
|
||||
export function planTools(ctx: PlanToolContext): Record<string, AgentTool<any>> {
|
||||
return {
|
||||
plan_init: lhTool({
|
||||
plan_init: {
|
||||
name: "plan_init",
|
||||
label: "plan_init",
|
||||
description:
|
||||
"Write or REWRITE this session's plan map — the dependency graph the learner follows live in the plan window. " +
|
||||
"Call once right after the probe concludes, to present the map; re-call whenever the map is wrong (the new block replaces the old tree). " +
|
||||
"A mermaid graph block (any direction): one node per line (`slug[Short Title]` — slugs are permanent lowercase ids: a-z, 0-9, hyphens/underscores), " +
|
||||
"`-->` edges meaning 'from must be understood before to' (chains fine). 5–15 nodes: a map, not a syllabus — one reasoning step per node, compress. " +
|
||||
"Every check you fire names the node it assesses via `nodeId`; where the lesson stands derives from those tags.",
|
||||
schema: z.object({
|
||||
mermaid: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"A mermaid graph block (any direction), e.g.:\ngraph TD\n primitives[Number primitives] --> operators[Arithmetic operators] --> expressions[Evaluating expressions]\n" +
|
||||
"The only strict rule: ids are permanent lowercase slugs (a-z, 0-9, hyphens/underscores) — checks reference them via nodeId. " +
|
||||
"Titles are free single-line text; `-->` means 'from = prerequisite' (chains fine); no subgraphs, styles, or classes.",
|
||||
),
|
||||
}),
|
||||
execute: async ({ mermaid }): Promise<PlanToolResult> => {
|
||||
parameters: planInitInput,
|
||||
execute: serverExecute("plan_init", planInitInput, async ({ mermaid }): Promise<PlanToolResult> => {
|
||||
const { chatId, chatStore } = ctx;
|
||||
const record = chatStore.getChat(chatId);
|
||||
if (!record) return { ok: false, error: `Chat not found: ${chatId}`, tree: emptyTree() };
|
||||
@@ -78,7 +81,7 @@ export function planTools(ctx: PlanToolContext): Record<string, LhTool<any, any>
|
||||
if (err instanceof PlanTreeError) return { ok: false, error: err.message, tree: current };
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lhTool, type LhTool } from "../lh-tool.ts";
|
||||
import { probeDoneInputSchema, type ProbeDoneInput, type ProbeDoneResult } from "../../core/contracts.ts";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { serverExecute } from "../tool-execute.ts";
|
||||
import { probeDoneInputSchema, type ProbeDoneResult } from "../../core/contracts.ts";
|
||||
import type { UIMessage } from "ai";
|
||||
import { findPendingToolCall } from "../../core/pending.ts";
|
||||
import type { ChatStore } from "../../core/ports.ts";
|
||||
@@ -43,10 +44,11 @@ function probeConcluded(messages: UIMessage[]): boolean {
|
||||
* The first probe_done in the transcript is also the construction seam:
|
||||
* from that message on, every request builds the TEACHER agent.
|
||||
*/
|
||||
export function probeDoneTool(ctx: ProbeDoneContext): Record<string, LhTool<any, any>> {
|
||||
export function probeDoneTool(ctx: ProbeDoneContext): Record<string, AgentTool<any>> {
|
||||
return {
|
||||
probe_done: lhTool({
|
||||
probe_done: {
|
||||
name: "probe_done",
|
||||
label: "probe_done",
|
||||
description:
|
||||
"Conclude the probing phase and hand off to the teaching phase. Call this ONCE, when the probe is " +
|
||||
"complete: every strand the lesson depends on is bracketed (a floor he reliably gets right AND a " +
|
||||
@@ -57,8 +59,8 @@ export function probeDoneTool(ctx: ProbeDoneContext): Record<string, LhTool<any,
|
||||
"the probe is already concluded. After it succeeds, say your closing sentence and end your turn.",
|
||||
// The core-owned schema (contracts/probe.ts) — the SAME schema the UI
|
||||
// parse seam (tool-parts.ts) runs, so boundary-valid ⇔ consumer-parseable.
|
||||
schema: probeDoneInputSchema,
|
||||
execute: async (input): Promise<ProbeDoneResult> => {
|
||||
parameters: probeDoneInputSchema,
|
||||
execute: serverExecute("probe_done", probeDoneInputSchema, async (input): Promise<ProbeDoneResult> => {
|
||||
const record = ctx.chatStore.getChat(ctx.chatId);
|
||||
if (!record) return { ok: false, error: `Chat not found: ${ctx.chatId}` };
|
||||
|
||||
@@ -82,7 +84,7 @@ export function probeDoneTool(ctx: ProbeDoneContext): Record<string, LhTool<any,
|
||||
// assessment anyway; the wake is an optimization, not a gate.
|
||||
}
|
||||
return { ok: true, assessment: input.assessment };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+15
-13
@@ -1,7 +1,8 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { lhTool, type LhToolImageResult } from "../lh-tool.ts";
|
||||
import { Type } from "typebox";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { serverExecute, type ImageFileResult } from "../tool-execute.ts";
|
||||
import { resolveSourcePath } from "./fs-shared.ts";
|
||||
|
||||
/**
|
||||
@@ -17,8 +18,8 @@ import { resolveSourcePath } from "./fs-shared.ts";
|
||||
* continuation notices — a huge file never silently floods the context.
|
||||
* - image files (jpg/png/gif/webp/bmp): returned as native image content
|
||||
* parts so the vision-capable model actually SEES them (teacher's
|
||||
* glm-5.3-flash accepts image input per pi's zai catalog; the converter
|
||||
* in lh-tool.ts maps LhToolImageResult to pi's ImageContent).
|
||||
* glm-5.3-flash accepts image input per pi's zai catalog; tool-execute's
|
||||
* toToolResult maps ImageFileResult to pi's ImageContent).
|
||||
* - unreadable/missing files throw — pi turns that into an error tool
|
||||
* result the model sees and recovers from.
|
||||
*
|
||||
@@ -41,10 +42,10 @@ const IMAGE_MIME: Record<string, string> = {
|
||||
".bmp": "image/bmp",
|
||||
};
|
||||
|
||||
const readInputSchema = z.object({
|
||||
path: z.string().min(1).describe("Path to the file to read (relative or absolute)"),
|
||||
offset: z.number().int().positive().optional().describe("Line number to start reading from (1-indexed)"),
|
||||
limit: z.number().int().positive().optional().describe("Maximum number of lines to read"),
|
||||
const readInput = Type.Object({
|
||||
path: Type.String({ minLength: 1, description: "Path to the file to read (relative or absolute)" }),
|
||||
offset: Type.Optional(Type.Integer({ minimum: 1, description: "Line number to start reading from (1-indexed)" })),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum number of lines to read" })),
|
||||
});
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
@@ -94,11 +95,12 @@ function readTextSlice(text: string, offset: number | undefined, limit: number |
|
||||
* fragment (prompts/shared/). Relative paths resolve against the
|
||||
* learner's home (fs-shared.ts), the harness's anchor.
|
||||
*/
|
||||
export const readTool = lhTool({
|
||||
export const readTool: AgentTool<typeof readInput> = {
|
||||
name: "read",
|
||||
label: "read",
|
||||
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50.0KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
|
||||
schema: readInputSchema,
|
||||
execute: async ({ path: rawPath, offset, limit }): Promise<string | LhToolImageResult> => {
|
||||
parameters: readInput,
|
||||
execute: serverExecute("read", readInput, async ({ path: rawPath, offset, limit }): Promise<string | ImageFileResult> => {
|
||||
const absolute = resolveSourcePath(rawPath);
|
||||
const ext = path.extname(absolute).toLowerCase();
|
||||
const mime = IMAGE_MIME[ext];
|
||||
@@ -114,5 +116,5 @@ export const readTool = lhTool({
|
||||
}
|
||||
const text = buffer.toString("utf-8");
|
||||
return readTextSlice(text, offset, limit);
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
+28
-19
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { lhTool } from "../lh-tool.ts";
|
||||
import { Type } from "typebox";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { serverExecute } from "../tool-execute.ts";
|
||||
// The search-hit shape lives in the core port since the search wave
|
||||
// (2026-09-20); this alias keeps callers churn-free.
|
||||
import type { SearchHit as WebSearchResult, SearchProvider } from "../../core/search.ts";
|
||||
@@ -114,17 +115,26 @@ async function fetchAndExtract(url: string): Promise<string> {
|
||||
|
||||
// ── tool registry ────────────────────────────────────────────────────────────
|
||||
|
||||
export function createWebTools(search: () => SearchProvider | null) {
|
||||
const webSearchInput = Type.Object({
|
||||
query: Type.String({ description: "The search query (a normal search string, phrases included)" }),
|
||||
count: Type.Optional(
|
||||
Type.Integer({ minimum: 1, maximum: 10, description: "Number of results (default 5, max 10)" }),
|
||||
),
|
||||
});
|
||||
|
||||
const webFetchInput = Type.Object({
|
||||
url: Type.String({ description: "The URL to fetch" }),
|
||||
});
|
||||
|
||||
export function createWebTools(search: () => SearchProvider | null): Record<string, AgentTool<any>> {
|
||||
return {
|
||||
web_search: lhTool({
|
||||
web_search: {
|
||||
name: "web_search",
|
||||
label: "web_search",
|
||||
description: "Search the web. One search per call — use one call per search angle. " +
|
||||
"Returns an array of results with title, url, and snippet.",
|
||||
schema: z.object({
|
||||
query: z.string().describe("The search query (a normal search string, phrases included)"),
|
||||
count: z.number().int().min(1).max(10).optional().describe("Number of results (default 5, max 10)"),
|
||||
}),
|
||||
execute: async ({ query, count }): Promise<WebSearchResult[] | string> => {
|
||||
parameters: webSearchInput,
|
||||
execute: serverExecute("web_search", webSearchInput, async ({ query, count }): Promise<WebSearchResult[] | string> => {
|
||||
const provider = search();
|
||||
if (!provider) {
|
||||
return (
|
||||
@@ -137,21 +147,20 @@ export function createWebTools(search: () => SearchProvider | null) {
|
||||
} catch (err) {
|
||||
return `web_search failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
|
||||
web_fetch: lhTool({
|
||||
web_fetch: {
|
||||
name: "web_fetch",
|
||||
label: "web_fetch",
|
||||
description:
|
||||
"Fetch a web page and extract its readable content as markdown (Readability). " +
|
||||
"Handles HTML pages and plain text; JS-rendered pages fall back to a reader service.",
|
||||
schema: z.object({
|
||||
url: z.string().describe("The URL to fetch"),
|
||||
}),
|
||||
execute: async ({ url }): Promise<string> => {
|
||||
parameters: webFetchInput,
|
||||
execute: serverExecute("web_fetch", webFetchInput, async ({ url }): Promise<string> => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("Only http(s) URLs are supported");
|
||||
if (parsed.protocol !== "http" && parsed.protocol !== "https") throw new Error("Only http(s) URLs are supported");
|
||||
} catch {
|
||||
return `web_fetch failed: invalid URL "${url}"`;
|
||||
}
|
||||
@@ -160,8 +169,8 @@ export function createWebTools(search: () => SearchProvider | null) {
|
||||
} catch (err) {
|
||||
return `web_fetch failed for ${url}: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user