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:
@@ -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, /~/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user