Skip to content

Commit ba002b0

Browse files
committed
Add planning, web access, checkpoints, and notes to Agent mode
Six new agent tools: web_search (DuckDuckGo HTML endpoint, no API key) and fetch_url for reading the web; read_notes/write_notes as a persistent per-workspace scratchpad (.agent-notes.md); set_plan, which renders a live step checklist above the chat instead of ever showing an approval card; and request_checkpoint, which pauses the loop with a Continue/Stop-here card so long runs can be reviewed mid-flight rather than only at the end.
1 parent 7b8bf90 commit ba002b0

4 files changed

Lines changed: 357 additions & 18 deletions

File tree

‎app/src/agent-tools.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import {
1717
gitDiff,
1818
gitLog,
1919
gitCommit,
20+
readNotes,
21+
writeNotes,
22+
fetchUrl,
2023
} from "./agent-tools";
2124

2225
describe("agent-tools", () => {
@@ -166,6 +169,33 @@ describe("agent-tools", () => {
166169
});
167170
});
168171

172+
describe("notes", () => {
173+
it("returns an empty string when no notes have been written yet", () => {
174+
expect(readNotes(workspace)).toBe("");
175+
});
176+
177+
it("round-trips written notes", () => {
178+
writeNotes(workspace, "step 1 done\nstep 2 in progress");
179+
expect(readNotes(workspace)).toBe("step 1 done\nstep 2 in progress");
180+
});
181+
182+
it("overwrites rather than appends on a second write", () => {
183+
writeNotes(workspace, "first");
184+
writeNotes(workspace, "second");
185+
expect(readNotes(workspace)).toBe("second");
186+
});
187+
});
188+
189+
describe("fetchUrl", () => {
190+
it("rejects a malformed URL", async () => {
191+
await expect(fetchUrl("not a url")).rejects.toThrow(/not a valid URL/);
192+
});
193+
194+
it("rejects a non-http(s) protocol", async () => {
195+
await expect(fetchUrl("file:///etc/passwd")).rejects.toThrow(/http:\/\/ and https:\/\//);
196+
});
197+
});
198+
169199
describe("executeTool", () => {
170200
it("dispatches to the right tool by name", async () => {
171201
writeFile(workspace, "y.txt", "z");

‎app/src/agent-tools.ts‎

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,80 @@ export const AGENT_TOOLS: ToolDefinition[] = [
121121
required: ["message"],
122122
},
123123
},
124+
{
125+
name: "web_search",
126+
description: "Search the web for a query and return the top results (title, URL, snippet). Use this to find information not available locally.",
127+
parameters: {
128+
type: "object",
129+
properties: {
130+
query: { type: "string", description: "The search query." },
131+
},
132+
required: ["query"],
133+
},
134+
},
135+
{
136+
name: "fetch_url",
137+
description: "Fetch a web page by URL and return its readable text content (HTML tags stripped). Use after web_search to read a specific result, or for any URL the user provides.",
138+
parameters: {
139+
type: "object",
140+
properties: {
141+
url: { type: "string", description: "The full URL to fetch, including https://." },
142+
},
143+
required: ["url"],
144+
},
145+
},
146+
{
147+
name: "read_notes",
148+
description: "Read the agent's persistent notes for this workspace — a scratchpad for tracking long-running context, decisions, or progress across turns and sessions. Empty if nothing has been written yet.",
149+
parameters: { type: "object", properties: {}, required: [] },
150+
},
151+
{
152+
name: "write_notes",
153+
description: "Overwrite the agent's persistent notes for this workspace with the given content. Use this to record progress, decisions, or context worth remembering later in a long task — write the full notes each time, not just an addition.",
154+
parameters: {
155+
type: "object",
156+
properties: {
157+
content: { type: "string", description: "The full notes content to save, replacing whatever was there before." },
158+
},
159+
required: ["content"],
160+
},
161+
},
162+
{
163+
name: "set_plan",
164+
description:
165+
"Declare or update a step-by-step plan for the current task, shown to the user as a checklist. Call this once at the start of any multi-step task, then call it again (with the full updated list) whenever a step is completed or the plan changes. Always pass the complete list, not just changes.",
166+
parameters: {
167+
type: "object",
168+
properties: {
169+
steps: {
170+
type: "array",
171+
items: {
172+
type: "object",
173+
properties: {
174+
text: { type: "string", description: "Short description of this step." },
175+
done: { type: "boolean", description: "Whether this step is already complete." },
176+
},
177+
required: ["text", "done"],
178+
},
179+
description: "The full, ordered list of steps.",
180+
},
181+
},
182+
required: ["steps"],
183+
},
184+
},
185+
{
186+
name: "request_checkpoint",
187+
description:
188+
"Pause and ask the user to confirm before continuing — use this after finishing a meaningful chunk of work or before starting something risky/irreversible, so the user can review progress rather than only finding out at the very end.",
189+
parameters: {
190+
type: "object",
191+
properties: {
192+
summary: { type: "string", description: "What's been done so far, in a sentence or two." },
193+
question: { type: "string", description: "What you'd like to confirm before continuing (optional)." },
194+
},
195+
required: ["summary"],
196+
},
197+
},
124198
];
125199

126200
const MAX_READ_CHARS = 100_000;
@@ -387,6 +461,110 @@ export async function gitCommit(workspaceRoot: string, message: string): Promise
387461
return gitCommand(workspaceRoot, `commit -m ${JSON.stringify(message)}`);
388462
}
389463

464+
const WEB_FETCH_TIMEOUT_MS = 15_000;
465+
const MAX_FETCH_CHARS = 30_000;
466+
const MAX_SEARCH_RESULTS_WEB = 5;
467+
468+
// Crude HTML-to-text: drop non-content tags outright, then strip remaining
469+
// markup and collapse whitespace. Not a real HTML parser — good enough for
470+
// giving a model readable page text without pulling in a DOM library in the
471+
// main process.
472+
function htmlToText(html: string): string {
473+
return html
474+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
475+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
476+
.replace(/<!--[\s\S]*?-->/g, " ")
477+
.replace(/<[^>]+>/g, " ")
478+
.replace(/&nbsp;/g, " ")
479+
.replace(/&amp;/g, "&")
480+
.replace(/&lt;/g, "<")
481+
.replace(/&gt;/g, ">")
482+
.replace(/&quot;/g, '"')
483+
.replace(/&#39;/g, "'")
484+
.replace(/[ \t]+/g, " ")
485+
.replace(/\n\s*\n+/g, "\n\n")
486+
.trim();
487+
}
488+
489+
export async function fetchUrl(url: string): Promise<string> {
490+
let parsed: URL;
491+
try {
492+
parsed = new URL(url);
493+
} catch {
494+
throw new Error(`"${url}" is not a valid URL.`);
495+
}
496+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
497+
throw new Error("Only http:// and https:// URLs can be fetched.");
498+
}
499+
500+
const res = await fetch(parsed, {
501+
signal: AbortSignal.timeout(WEB_FETCH_TIMEOUT_MS),
502+
headers: { "User-Agent": "Mozilla/5.0 (compatible; Modelforge/1.0)" },
503+
});
504+
if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
505+
const contentType = res.headers.get("content-type") ?? "";
506+
const raw = await res.text();
507+
const text = contentType.includes("html") ? htmlToText(raw) : raw;
508+
return text.length > MAX_FETCH_CHARS
509+
? `${text.slice(0, MAX_FETCH_CHARS)}\n\n[truncated — page is ${text.length} characters]`
510+
: text;
511+
}
512+
513+
export interface WebSearchResult {
514+
title: string;
515+
url: string;
516+
snippet: string;
517+
}
518+
519+
// Uses DuckDuckGo's keyless HTML endpoint (no API key/signup needed, unlike
520+
// most search APIs) and regex-scrapes the result markup — brittle if DDG
521+
// changes its HTML, but keeps this tool usable out of the box with zero
522+
// configuration, consistent with the rest of Agent mode's tools.
523+
export async function webSearch(query: string): Promise<WebSearchResult[]> {
524+
const res = await fetch(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`, {
525+
signal: AbortSignal.timeout(WEB_FETCH_TIMEOUT_MS),
526+
headers: { "User-Agent": "Mozilla/5.0 (compatible; Modelforge/1.0)" },
527+
});
528+
if (!res.ok) throw new Error(`Web search failed: HTTP ${res.status}`);
529+
const html = await res.text();
530+
531+
const results: WebSearchResult[] = [];
532+
const linkPattern = /<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
533+
const snippetPattern = /<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi;
534+
const links = [...html.matchAll(linkPattern)];
535+
const snippets = [...html.matchAll(snippetPattern)];
536+
537+
for (let i = 0; i < links.length && results.length < MAX_SEARCH_RESULTS_WEB; i++) {
538+
const href = links[i][1];
539+
// DuckDuckGo's HTML endpoint wraps result URLs in a redirect
540+
// (/l/?uddg=<encoded target>) rather than linking straight to them.
541+
const uddgMatch = href.match(/[?&]uddg=([^&]+)/);
542+
const url = uddgMatch ? decodeURIComponent(uddgMatch[1]) : href;
543+
results.push({
544+
title: htmlToText(links[i][2]),
545+
url,
546+
snippet: htmlToText(snippets[i]?.[1] ?? ""),
547+
});
548+
}
549+
return results;
550+
}
551+
552+
function notesPath(): string {
553+
return ".agent-notes.md";
554+
}
555+
556+
export function readNotes(workspaceRoot: string): string {
557+
try {
558+
return readFile(workspaceRoot, notesPath());
559+
} catch {
560+
return "";
561+
}
562+
}
563+
564+
export function writeNotes(workspaceRoot: string, content: string): { bytesWritten: number } {
565+
return writeFile(workspaceRoot, notesPath(), content);
566+
}
567+
390568
export interface ProjectScripts {
391569
test?: string;
392570
lint?: string;
@@ -436,6 +614,14 @@ export async function executeTool(workspaceRoot: string, name: string, args: Rec
436614
return gitLog(workspaceRoot, typeof args.count === "number" ? args.count : 10);
437615
case "git_commit":
438616
return gitCommit(workspaceRoot, String(args.message ?? ""));
617+
case "web_search":
618+
return webSearch(String(args.query ?? ""));
619+
case "fetch_url":
620+
return fetchUrl(String(args.url ?? ""));
621+
case "read_notes":
622+
return readNotes(workspaceRoot);
623+
case "write_notes":
624+
return writeNotes(workspaceRoot, String(args.content ?? ""));
439625
default:
440626
throw new Error(`Unknown tool: ${name}`);
441627
}

‎frontend/src/lib/translations.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ export interface Dictionary {
2727
alwaysAllowThisSession: string;
2828
agentStep: string;
2929
agentStepTooltip: string;
30+
agentPlan: string;
31+
agentCheckpoint: string;
32+
continueAgent: string;
33+
stopAgent: string;
3034
ollamaServer: string;
3135
serverAddress: string;
3236
serverAddressHelp: string;
@@ -305,6 +309,10 @@ export const en: Dictionary = {
305309
alwaysAllowThisSession: "Always allow this session",
306310
agentStep: "Agent step",
307311
agentStepTooltip: "How many automatic tool-result → model-continuation round trips have happened for this turn.",
312+
agentPlan: "Plan",
313+
agentCheckpoint: "Checkpoint",
314+
continueAgent: "Continue",
315+
stopAgent: "Stop here",
308316
ollamaServer: "Ollama server",
309317
serverAddress: "Server address",
310318
serverAddressHelp:
@@ -592,6 +600,10 @@ export const tr: Dictionary = {
592600
alwaysAllowThisSession: "Bu oturumda her zaman izin ver",
593601
agentStep: "Ajan adımı",
594602
agentStepTooltip: "Bu tur için kaç otomatik araç sonucu → model devamı gidiş-dönüşü gerçekleşti.",
603+
agentPlan: "Plan",
604+
agentCheckpoint: "Kontrol noktası",
605+
continueAgent: "Devam et",
606+
stopAgent: "Burada dur",
595607
ollamaServer: "Ollama sunucusu",
596608
serverAddress: "Sunucu adresi",
597609
serverAddressHelp:

0 commit comments

Comments
 (0)