feat(sources v1): the agent locates files itself — ls/find/grep + tool-call cards

User pivot (2026-09-19): no attachments UI at all. Pasting paths is native
for technical users and alien for everyone else - so the agent gets the
tools to find what the learner merely NAMES. The learner communicates in
messages; the agent locates and reads. Zero cross-stream seams: paths
already live in tool parts, so the sources "list" is the transcript.

- ls/find/grep: pi-verbatim schemas, descriptions, and output formats
  (fd's glob semantics, rg's path:line: match format, pi's truncation
  notices), ported to pure Node - no binaries, no shell-out, read-only by
  construction. Deltas from pi: home-anchored resolution (the harness has
  no cwd), .gitignore replaced by hard noise exclusions
  (node_modules/.git/Library/.Trash), 10s walk timeout with partial results.
- read: description swapped to pi-verbatim (doctrine moved to the prompt);
  relative paths now resolve against the learner's home like the rest.
- prompts/shared/05-learner-files.md: one minimal doctrine fragment
  composed into BOTH agents (composeInstructions grew a shared/ dir,
  before the agent's own fragments). teacher/30-jobs.md keeps only the
  pdf-dispatch addendum.
- ToolCallCard: the generic quiet tool-call card the user specced -
  collapsed one-liner (tool name + headline argument), expandable to full
  input/output, errors red, image payloads never render. Registered for
  read/ls/find/grep.
- ui-message.ts: TeacherUITools entries for the four tools (mechanical).
- tests: fs-tools suite (glob semantics, pi formats, exclusions, limits),
  registry/card renders, shared-fragment composition pins, tool-registry
  key updates, read re-anchor.
This commit is contained in:
amosblomqvist
2026-09-19 16:50:20 +03:00
parent dfe1a21989
commit 7a3e19e95f
18 changed files with 977 additions and 41 deletions
+63
View File
@@ -41,6 +41,7 @@ import { sanitizeSvg, VisualCard } from "../components/artifacts/visual-card.tsx
import { SubagentSteerCard } from "../components/artifacts/steer-card.tsx";
import { DispatchCard } from "../components/artifacts/dispatch-card.tsx";
import { JobCard, PdfJobCard } from "../components/artifacts/job-card.tsx";
import { ToolCallCard } from "../components/artifacts/tool-call-card.tsx";
import { JobsContext } from "../components/jobs-context.ts";
import { demoInputSchema, jobTools, pdfInputSchema, visualInputSchema } from "../src/agent/tools/jobs.ts";
import { runDemoBuilder } from "../src/agent/subagents/demo-builder.ts";
@@ -846,5 +847,67 @@ describe("tool + renderer registry wiring", () => {
assert.equal(artifactRenderers.visual, VisualCard);
// sources v0 — pdf maps to its dispatch card (the live-smoke gap)
assert.equal(artifactRenderers.pdf, PdfJobCard);
// sources v1 — the learner-filesystem set maps to the generic tool-call card
for (const t of ["read", "ls", "find", "grep"]) {
assert.equal(artifactRenderers[t], ToolCallCard, `${t} renders the tool-call card`);
}
});
it("the tool-call card: collapsed one-liner with the tool name + headline arg; expand shows input and output", () => {
const part = {
type: "tool-read",
state: "output-available",
toolCallId: "c1",
input: { path: "notes/calculus.md", offset: 1, limit: 20 },
output: "Calculus notes\nThe derivative measures force.",
};
const html = renderToStaticMarkup(<ToolCallCard part={part as never} />);
// collapsed headline: tool name + path as given
assert.match(html, /read/);
assert.match(html, /notes\/calculus\.md/);
// expandable payload: input JSON + output text
assert.match(html, /Input/);
assert.match(html, /Output/);
assert.match(html, /The derivative measures force\./);
assert.ok(html.includes("<details"), "collapsed by default via a details element");
});
it("the tool-call card: image-bearing read results note the attachment; errors render red", () => {
const imgPart = {
type: "tool-read",
state: "output-available",
toolCallId: "c2",
input: { path: "shot.png" },
output: { text: "Read image file [image/png].", images: [{ data: "AAAA", mimeType: "image/png" }] },
};
const html = renderToStaticMarkup(<ToolCallCard part={imgPart as never} />);
assert.match(html, /Read image file \[image\/png\]\./);
assert.match(html, /1 image attached to the model/);
assert.ok(!html.includes("AAAA"), "the image payload itself never renders");
const errPart = {
type: "tool-read",
state: "output-error",
toolCallId: "c3",
input: { path: "/gone.md" },
errorText: "ENOENT: no such file",
};
const errHtml = renderToStaticMarkup(<ToolCallCard part={errPart as never} />);
assert.match(errHtml, /Error/);
assert.match(errHtml, /ENOENT/);
assert.match(errHtml, /text-red-500/);
});
it("the tool-call card: find/grep headline the pattern, ls defaults to ~", () => {
const findHtml = renderToStaticMarkup(
<ToolCallCard part={{ type: "tool-find", state: "output-available", toolCallId: "c", input: { pattern: "*calculus*" }, output: "notes/calculus.md" } as never} />,
);
assert.match(findHtml, /find/);
assert.match(findHtml, /\*calculus\*/);
const lsHtml = renderToStaticMarkup(
<ToolCallCard part={{ type: "tool-ls", state: "output-available", toolCallId: "c", input: {}, output: "notes/" } as never} />,
);
assert.match(lsHtml, /ls/);
assert.match(lsHtml, /~/);
});
});
+188
View File
@@ -0,0 +1,188 @@
import assert from "node:assert";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { describe, it } from "node:test";
import { expandBraces, globToRegex, matchGlob } from "../src/agent/tools/fs-shared.ts";
import { findTool } from "../src/agent/tools/find.ts";
import { grepTool } from "../src/agent/tools/grep.ts";
import { lsTool } from "../src/agent/tools/ls.ts";
import { readTool } from "../src/agent/tools/read.ts";
/**
* The learner-filesystem tools (sources v1, 2026-09-19): ls/find/grep as
* pure-Node pi ports, read re-anchored to the home directory. All calls go
* through absolute paths — the tools' relative resolution targets the real
* home, which tests must not touch.
*
* Fixture tree (tmpdir):
* notes/calculus.md "The derivative measures force…"
* notes/linear.txt eigenvector content
* notes/sub/hidden.md
* code/main.ts
* node_modules/skipme.txt (excluded from walks)
* Library/alsoskip.txt (excluded from walks)
* binary.bin NUL byte — grep skips
*/
let FIXTURE: string;
async function setup(): Promise<string> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "lh-fs-"));
await fs.mkdir(path.join(root, "notes", "sub"), { recursive: true });
await fs.mkdir(path.join(root, "code"), { recursive: true });
await fs.mkdir(path.join(root, "node_modules"), { recursive: true });
await fs.mkdir(path.join(root, "Library"), { recursive: true });
await fs.writeFile(path.join(root, "notes", "calculus.md"), "Calculus notes\nThe derivative measures force.\nChapter 1\n");
await fs.writeFile(path.join(root, "notes", "linear.txt"), "Eigenvectors keep their direction\nonly scaling remains\n");
await fs.writeFile(path.join(root, "notes", "sub", "hidden.md"), "deep file about MU\n");
await fs.writeFile(path.join(root, "code", "main.ts"), "const x = 1;\nconst eigenvector = 'here';\n");
await fs.writeFile(path.join(root, "node_modules", "skipme.txt"), "eigenvector in node_modules\n");
await fs.writeFile(path.join(root, "Library", "alsoskip.txt"), "eigenvector in Library\n");
await fs.writeFile(path.join(root, "binary.bin"), Buffer.from([0x00, 0x01, 0x02, 0x65, 0x69, 0x67, 0x03]));
return root;
}
describe("glob helpers (fs-shared)", () => {
it("basename mode: a pattern without a slash matches at any depth", () => {
assert.equal(matchGlob("notes/sub/calculus.md", "*.md"), true);
assert.equal(matchGlob("calculus.md", "*.md"), true);
assert.equal(matchGlob("notes/calculus.md", "*.ts"), false);
});
it("star does not cross slashes; double-star does", () => {
assert.equal(matchGlob("a/b/c.txt", "*.txt"), true); // basename mode
assert.equal(matchGlob("src/x/a.spec.ts", "src/**/*.spec.ts"), true);
assert.equal(matchGlob("other/x/a.spec.ts", "src/**/*.spec.ts"), false);
assert.equal(matchGlob("src/a.spec.ts", "src/**/*.spec.ts"), true, "** covers zero dirs");
});
it("slash patterns get an implied deep prefix unless anchored", () => {
assert.equal(matchGlob("deep/nested/x/main.ts", "**/nested/**/main.ts"), true);
assert.equal(matchGlob("x/main.ts", "nested/main.ts"), false, "no implied prefix for anchored-looking patterns");
});
it("brace alternatives expand", () => {
assert.deepEqual(expandBraces("*.{ts,tsx}"), ["*.ts", "*.tsx"]);
assert.equal(matchGlob("code/main.ts", "*.{ts,tsx,js}"), true);
assert.equal(matchGlob("code/main.ts", "*.{js,jsx}"), false);
});
it("question mark is one char, literals are escaped", () => {
assert.equal(globToRegex("a?c").test("abc"), true);
assert.equal(globToRegex("a?c").test("ac"), false);
assert.equal(matchGlob("a.b.txt", "a.b*"), true, "the dot is literal, not any-char");
});
});
describe("ls (pi port)", () => {
it("lists sorted, dirs suffixed, dotfiles included", async () => {
FIXTURE = await setup();
const out = (await lsTool.execute!({ path: FIXTURE }, { toolCallId: "c" })) as string;
assert.ok(out.includes("code/"));
assert.ok(out.includes("notes/"));
assert.ok(out.includes("binary.bin"));
// sorted alphabetically, case-insensitive
const lines = out.split("\n").filter((l) => !l.startsWith("["));
const sorted = [...lines].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
assert.deepEqual(lines, sorted);
});
it("honors the entry limit with pi's truncation notice", async () => {
const out = (await lsTool.execute!({ path: FIXTURE, limit: 2 }, { toolCallId: "c" })) as string;
assert.match(out, /\[Truncated: 2 entries limit\]/);
});
it("missing path and non-directory throw (the model sees the error)", async () => {
await assert.rejects(async () => lsTool.execute!({ path: "/nonexistent/xyz" }, { toolCallId: "c" }), /Path not found/);
await assert.rejects(
async () => lsTool.execute!({ path: path.join(FIXTURE, "binary.bin") }, { toolCallId: "c" }),
/Not a directory/,
);
});
});
describe("find (pi port)", () => {
it("finds by glob, relativized to the search root", async () => {
const out = (await findTool.execute!({ pattern: "*.md", path: FIXTURE }, { toolCallId: "c" })) as string;
assert.ok(out.includes("notes/calculus.md"));
assert.ok(out.includes("notes/sub/hidden.md"));
assert.ok(!out.includes(FIXTURE), "paths are relative, not absolute");
});
it("never enters node_modules or Library", async () => {
const out = (await findTool.execute!({ pattern: "*.txt", path: FIXTURE }, { toolCallId: "c" })) as string;
assert.ok(!out.includes("node_modules"));
assert.ok(!out.includes("Library"));
assert.ok(out.includes("notes/linear.txt"));
});
it("result limit carries pi's notice", async () => {
const out = (await findTool.execute!({ pattern: "*", path: FIXTURE, limit: 2 }, { toolCallId: "c" })) as string;
assert.match(out, /\[2 results limit reached\]/);
});
it("no-match message matches pi", async () => {
const out = (await findTool.execute!({ pattern: "*.nomatch", path: FIXTURE }, { toolCallId: "c" })) as string;
assert.equal(out, "No files found matching pattern");
});
it("slash patterns match paths, not basenames", async () => {
const out = (await findTool.execute!({ pattern: "notes/**/*.md", path: FIXTURE }, { toolCallId: "c" })) as string;
assert.ok(out.includes("notes/sub/hidden.md"));
assert.ok(!out.includes("code/"));
});
});
describe("grep (pi port)", () => {
it("formats matches pi-style: rel:line: text", async () => {
// case-sensitive: the lowercase "eigenvector" hits main.ts but NOT
// linear.txt's capital-E "Eigenvectors" (that case lives below).
const out = (await grepTool.execute!({ pattern: "eigenvector", path: FIXTURE }, { toolCallId: "c" })) as string;
assert.equal(out, "code/main.ts:2: const eigenvector = 'here';");
assert.ok(!out.includes("node_modules"), "excluded dirs are not searched");
assert.ok(!out.includes("Library"));
assert.ok(!out.includes("binary.bin"), "binary files are skipped");
});
it("ignoreCase and literal flags", async () => {
const icase = (await grepTool.execute!({ pattern: "EIGENVECTOR", path: FIXTURE, ignoreCase: true }, { toolCallId: "c" })) as string;
assert.match(icase, /linear\.txt:1:/);
const literal = (await grepTool.execute!({ pattern: "e.gen", literal: true, path: path.join(FIXTURE, "code") }, { toolCallId: "c" })) as string;
assert.equal(literal, "No matches found", "dots are literal when literal=true");
});
it("context lines use the dash format", async () => {
const out = (await grepTool.execute!({ pattern: "scaling", path: FIXTURE, context: 1 }, { toolCallId: "c" })) as string;
assert.match(out, /notes\/linear\.txt:2: only scaling remains/, "the match line uses colons");
assert.match(out, /notes\/linear\.txt-1- Eigenvectors keep their direction/, "context before uses dashes");
assert.match(out, /notes\/linear\.txt-3- $/, "context after (empty line) uses dashes");
});
it("glob filter restricts files", async () => {
const out = (await grepTool.execute!({ pattern: "eigenvector", path: FIXTURE, glob: "*.ts" }, { toolCallId: "c" })) as string;
assert.match(out, /main\.ts:2:/);
assert.ok(!out.includes("linear.txt"));
});
it("single-file search prints basename; match limit notices pi-style", async () => {
const file = path.join(FIXTURE, "notes", "calculus.md");
const out = (await grepTool.execute!({ pattern: "e", path: file }, { toolCallId: "c" })) as string;
assert.match(out, /^calculus\.md:1: /, "basename for a single-file search");
const limited = (await grepTool.execute!({ pattern: "e", path: file, limit: 1 }, { toolCallId: "c" })) as string;
assert.match(limited, /\[1 matches limit reached\]/);
});
it("no-match message matches pi", async () => {
const out = (await grepTool.execute!({ pattern: "zzzz", path: FIXTURE }, { toolCallId: "c" })) as string;
assert.equal(out, "No matches found");
});
});
describe("read (home-anchored re-port)", () => {
it("reads a fixture file verbatim through the shared slice machinery", async () => {
const out = (await readTool.execute!({ path: path.join(FIXTURE, "notes", "linear.txt") }, { toolCallId: "c" })) as string;
assert.match(out, /Eigenvectors keep their direction/);
});
});
+15
View File
@@ -1089,6 +1089,21 @@ describe("prompt composition", () => {
assert.ok(prober.includes("the `read` tool can inspect it directly"), "the prober knows it can read learner files");
});
it("the sources-v1 shared fragment composes into BOTH agents (find/ls/grep doctrine)", () => {
for (const kind of ["teacher", "prober"] as const) {
const instructions = composeInstructions(kind);
assert.ok(instructions.includes("## Learner files"), `${kind} carries the shared learner-files fragment`);
assert.ok(instructions.includes("find it yourself with `find`"), `${kind} knows to locate files itself`);
assert.ok(instructions.includes("home directory"), `${kind} knows the home-anchored path rule`);
}
// shared composes BEFORE the agent's own fragments (the seam's order rule)
const teacher = composeInstructions("teacher");
assert.ok(
teacher.indexOf("## Learner files") < teacher.indexOf("## Learner source files"),
"shared fragment precedes the teacher's own 30-jobs section",
);
});
it("guards prompt size — no fragment can silently balloon (the README-leak class)", () => {
// The wave-1 leak: README.md (815 bytes / ~200 tokens) rode into every
// teacher turn until the numeric-prefix filter stopped it. This guard
+7 -7
View File
@@ -143,10 +143,10 @@ describe("the prober's quiz: no nodeId field at all", () => {
assert.equal("nodeId" in smuggled.data, false, "stripped, not tolerated");
});
it("the prober's tool registry carries the nodeId-less quiz, probe_done, and the shared read tool", () => {
it("the prober's tool registry carries the nodeId-less quiz, probe_done, and the learner-filesystem set", () => {
const chatId = newChat("Prober: registry");
const tools = createProberTools({ chatId, chatStore, deliver: () => {} });
assert.deepEqual(Object.keys(tools).sort(), ["ask_user_question", "probe_done", "quiz", "read"]);
assert.deepEqual(Object.keys(tools).sort(), ["ask_user_question", "find", "grep", "ls", "probe_done", "quiz", "read"]);
const schema = quizSchemaOf(tools);
assert.equal(schema.safeParse({ ...validQuiz, nodeId: "anything" }).success, true, "no session check to fail");
});
@@ -165,15 +165,15 @@ describe("tool toggles (core/pending.ts + factories)", () => {
it("createProberTools: absent record = full set; explicit false drops exactly that tool; probe_done survives everything", () => {
const chatId = newChat("Prober: toggles");
const ctx = { chatId, chatStore, deliver: () => {} };
assert.deepEqual(Object.keys(createProberTools(ctx)).sort(), ["ask_user_question", "probe_done", "quiz", "read"]);
assert.deepEqual(Object.keys(createProberTools(ctx)).sort(), ["ask_user_question", "find", "grep", "ls", "probe_done", "quiz", "read"]);
const noAsk = createProberTools(ctx, { ask_user_question: false });
assert.deepEqual(Object.keys(noAsk).sort(), ["probe_done", "quiz", "read"]);
assert.deepEqual(Object.keys(noAsk).sort(), ["find", "grep", "ls", "probe_done", "quiz", "read"]);
// quiz off is honored by the generic machinery too (future toggleable),
// but probe_done is never droppable — it is how the phase ends.
const noQuiz = createProberTools(ctx, { quiz: false });
assert.deepEqual(Object.keys(noQuiz).sort(), ["ask_user_question", "probe_done", "read"]);
assert.deepEqual(Object.keys(noQuiz).sort(), ["ask_user_question", "find", "grep", "ls", "probe_done", "read"]);
assert.ok(createProberTools(ctx, { probe_done: false }).probe_done, "probe_done always-on");
});
@@ -186,10 +186,10 @@ describe("tool toggles (core/pending.ts + factories)", () => {
spawn: () => {},
};
const full = Object.keys(createTeacherTools(ctx)).sort();
assert.deepEqual(full, ["ask_user_question", "demo", "pdf", "plan_init", "quiz", "read", "research", "visual"]);
assert.deepEqual(full, ["ask_user_question", "demo", "find", "grep", "ls", "pdf", "plan_init", "quiz", "read", "research", "visual"]);
const noResearch = Object.keys(createTeacherTools(ctx, { research: false })).sort();
assert.deepEqual(noResearch, ["ask_user_question", "demo", "pdf", "plan_init", "quiz", "read", "visual"]);
assert.deepEqual(noResearch, ["ask_user_question", "demo", "find", "grep", "ls", "pdf", "plan_init", "quiz", "read", "visual"]);
// absent = enabled: an empty record and undefined behave identically
assert.deepEqual(
+9 -4
View File
@@ -31,12 +31,17 @@ describe("read tool — text semantics", () => {
assert.equal(await run({ path: file }), "hello\nworld");
});
it("resolves relative paths against cwd", async () => {
fs.writeFileSync(path.join(process.cwd(), "lh-read-rel-test.txt"), "rel", "utf-8");
it("resolves relative paths against the learner's home (sources v1 anchoring)", async () => {
// sources v1: the harness has no meaningful cwd — "find my notes" must
// work from a bare relative path, so the home directory is the anchor
// (fs-shared resolveSourcePath). Scratch file in home, cleaned up.
const rel = `lh-read-rel-test-${process.pid}.txt`;
const homeFile = path.join(os.homedir(), rel);
fs.writeFileSync(homeFile, "rel", "utf-8");
try {
assert.equal(await run({ path: "lh-read-rel-test.txt" }), "rel");
assert.equal(await run({ path: rel }), "rel");
} finally {
fs.unlinkSync(path.join(process.cwd(), "lh-read-rel-test.txt"));
fs.unlinkSync(homeFile);
}
});