Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions cli/templates/files/coding-agent/tools/edit-file.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { tool } from "veryfront/tool";
import { defineSchema } from "veryfront/schemas";
import { readTextFile, writeTextFile, resolve, cwd } from "veryfront/fs";
import { cwd, readTextFile, realPath, resolve, writeTextFile } from "veryfront/fs";

/** True when `target` is the same as, or nested under, `root` (both canonical). */
function isWithin(root: string, target: string): boolean {
const r = root.replace(/\\/g, "/");
const t = target.replace(/\\/g, "/");
return t === r || t.startsWith(`${r}/`);
}

export default tool({
id: "edit-file",
Expand All @@ -11,15 +18,21 @@ export default tool({
replace: v.string().describe("String to replace it with"),
}))(),
execute: async ({ path, search, replace }) => {
const absolute = resolve(cwd(), path);

let content: string;
let projectDir: string;
let absolute: string;
try {
content = await readTextFile(absolute);
// Canonicalize both sides so a symlink that points outside the project
// is resolved to its real target before the containment check.
projectDir = await realPath(cwd());
absolute = await realPath(resolve(projectDir, path));
} catch {
return { error: `File not found: ${path}` };
}
if (!isWithin(projectDir, absolute)) {
return { error: `Path escapes project directory: ${path}` };
}

const content = await readTextFile(absolute);
if (!content.includes(search)) {
return { error: "Search string not found in file" };
}
Expand Down
24 changes: 22 additions & 2 deletions cli/templates/files/coding-agent/tools/list-files.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { tool } from "veryfront/tool";
import { defineSchema } from "veryfront/schemas";
import { readDir, resolve, cwd } from "veryfront/fs";
import { cwd, readDir, realPath, resolve } from "veryfront/fs";

/** True when `target` is the same as, or nested under, `root` (both canonical). */
function isWithin(root: string, target: string): boolean {
const r = root.replace(/\\/g, "/");
const t = target.replace(/\\/g, "/");
return t === r || t.startsWith(`${r}/`);
}

export default tool({
id: "list-files",
Expand All @@ -16,7 +23,20 @@ export default tool({
.describe("Filter by file extensions (e.g. ['.ts', '.tsx'])"),
}))(),
execute: async ({ directory, extensions }) => {
const absolute = resolve(cwd(), directory);
let projectDir: string;
let absolute: string;
try {
// Canonicalize both sides so a symlink that points outside the project
// is resolved to its real target before the containment check.
projectDir = await realPath(cwd());
absolute = await realPath(resolve(projectDir, directory));
} catch {
return { error: `Directory not found: ${directory}` };
}
if (!isWithin(projectDir, absolute)) {
return { error: `Path escapes project directory: ${directory}` };
}

const entries = await readDir(absolute);

let files = entries
Expand Down
23 changes: 19 additions & 4 deletions cli/templates/files/coding-agent/tools/read-file.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { tool } from "veryfront/tool";
import { defineSchema } from "veryfront/schemas";
import { readTextFile, resolve, cwd } from "veryfront/fs";
import { cwd, readTextFile, realPath, resolve } from "veryfront/fs";

/** True when `target` is the same as, or nested under, `root` (both canonical). */
function isWithin(root: string, target: string): boolean {
const r = root.replace(/\\/g, "/");
const t = target.replace(/\\/g, "/");
return t === r || t.startsWith(`${r}/`);
}

export default tool({
id: "read-file",
Expand All @@ -9,12 +16,20 @@ export default tool({
path: v.string().describe("File path relative to the project root"),
}))(),
execute: async ({ path }) => {
let projectDir: string;
let absolute: string;
try {
const absolute = resolve(cwd(), path);
const content = await readTextFile(absolute);
return { path, content };
// Canonicalize both sides so a symlink that points outside the project
// is resolved to its real target before the containment check.
projectDir = await realPath(cwd());
absolute = await realPath(resolve(projectDir, path));
} catch {
return { error: `File not found: ${path}` };
}
if (!isWithin(projectDir, absolute)) {
return { error: `Path escapes project directory: ${path}` };
}
const content = await readTextFile(absolute);
return { path, content };
},
});
6 changes: 3 additions & 3 deletions cli/templates/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@
"app/page.tsx": "'use client'\n\nimport { Chat, useChat } from 'veryfront/chat'\n\nexport default function CodeAgent(): JSX.Element {\n const chat = useChat({ api: '/api/ag-ui' })\n\n return (\n <Chat\n {...chat}\n className=\"flex-1 min-h-0\"\n placeholder=\"Describe what you want to build or fix...\"\n />\n )\n}\n",
"globals.css": "@import \"tailwindcss\";\n",
"README.md": "# Coding Agent\n\nAn AI assistant that can read, understand, and modify project files.\n\n## What's included\n\n- Coder agent with file system tools\n- Read, list, and edit files through conversation\n- Safe search/replace editing pattern\n\n## Structure\n\n```\nagents/coder.ts Agent with coding instructions\ntools/\n read-file.ts Read file contents\n list-files.ts List directory contents\n edit-file.ts Search and replace in files\napp/\n api/ag-ui/route.ts AG-UI endpoint\n page.tsx Chat interface\n```\n\nThis starter is not production-ready.\n",
"tools/edit-file.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { readTextFile, writeTextFile, resolve, cwd } from \"veryfront/fs\";\n\nexport default tool({\n id: \"edit-file\",\n description: \"Edit a file by replacing a specific string with new content\",\n inputSchema: defineSchema((v) => v.object({\n path: v.string().describe(\"File path relative to the project root\"),\n search: v.string().describe(\"Exact string to find in the file\"),\n replace: v.string().describe(\"String to replace it with\"),\n }))(),\n execute: async ({ path, search, replace }) => {\n const absolute = resolve(cwd(), path);\n\n let content: string;\n try {\n content = await readTextFile(absolute);\n } catch {\n return { error: `File not found: ${path}` };\n }\n\n if (!content.includes(search)) {\n return { error: \"Search string not found in file\" };\n }\n\n const updated = content.replace(search, replace);\n await writeTextFile(absolute, updated);\n return { path, success: true };\n },\n});\n",
"tools/list-files.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { readDir, resolve, cwd } from \"veryfront/fs\";\n\nexport default tool({\n id: \"list-files\",\n description: \"List files in a project directory\",\n inputSchema: defineSchema((v) => v.object({\n directory: v\n .string()\n .default(\".\")\n .describe(\"Directory path relative to project root\"),\n extensions: v\n .array(v.string())\n .optional()\n .describe(\"Filter by file extensions (e.g. ['.ts', '.tsx'])\"),\n }))(),\n execute: async ({ directory, extensions }) => {\n const absolute = resolve(cwd(), directory);\n const entries = await readDir(absolute);\n\n let files = entries\n .filter((e) => e.isFile)\n .map((e) => e.name);\n\n if (extensions?.length) {\n files = files.filter((f) =>\n extensions.some((ext) => f.endsWith(ext))\n );\n }\n\n return { directory, files, count: files.length };\n },\n});\n",
"tools/read-file.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { readTextFile, resolve, cwd } from \"veryfront/fs\";\n\nexport default tool({\n id: \"read-file\",\n description: \"Read the contents of a file in the project\",\n inputSchema: defineSchema((v) => v.object({\n path: v.string().describe(\"File path relative to the project root\"),\n }))(),\n execute: async ({ path }) => {\n try {\n const absolute = resolve(cwd(), path);\n const content = await readTextFile(absolute);\n return { path, content };\n } catch {\n return { error: `File not found: ${path}` };\n }\n },\n});\n",
"tools/edit-file.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { cwd, readTextFile, realPath, resolve, writeTextFile } from \"veryfront/fs\";\n\n/** True when `target` is the same as, or nested under, `root` (both canonical). */\nfunction isWithin(root: string, target: string): boolean {\n const r = root.replace(/\\\\/g, \"/\");\n const t = target.replace(/\\\\/g, \"/\");\n return t === r || t.startsWith(`${r}/`);\n}\n\nexport default tool({\n id: \"edit-file\",\n description: \"Edit a file by replacing a specific string with new content\",\n inputSchema: defineSchema((v) => v.object({\n path: v.string().describe(\"File path relative to the project root\"),\n search: v.string().describe(\"Exact string to find in the file\"),\n replace: v.string().describe(\"String to replace it with\"),\n }))(),\n execute: async ({ path, search, replace }) => {\n let projectDir: string;\n let absolute: string;\n try {\n // Canonicalize both sides so a symlink that points outside the project\n // is resolved to its real target before the containment check.\n projectDir = await realPath(cwd());\n absolute = await realPath(resolve(projectDir, path));\n } catch {\n return { error: `File not found: ${path}` };\n }\n if (!isWithin(projectDir, absolute)) {\n return { error: `Path escapes project directory: ${path}` };\n }\n\n const content = await readTextFile(absolute);\n if (!content.includes(search)) {\n return { error: \"Search string not found in file\" };\n }\n\n const updated = content.replace(search, replace);\n await writeTextFile(absolute, updated);\n return { path, success: true };\n },\n});\n",
"tools/list-files.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { cwd, readDir, realPath, resolve } from \"veryfront/fs\";\n\n/** True when `target` is the same as, or nested under, `root` (both canonical). */\nfunction isWithin(root: string, target: string): boolean {\n const r = root.replace(/\\\\/g, \"/\");\n const t = target.replace(/\\\\/g, \"/\");\n return t === r || t.startsWith(`${r}/`);\n}\n\nexport default tool({\n id: \"list-files\",\n description: \"List files in a project directory\",\n inputSchema: defineSchema((v) => v.object({\n directory: v\n .string()\n .default(\".\")\n .describe(\"Directory path relative to project root\"),\n extensions: v\n .array(v.string())\n .optional()\n .describe(\"Filter by file extensions (e.g. ['.ts', '.tsx'])\"),\n }))(),\n execute: async ({ directory, extensions }) => {\n let projectDir: string;\n let absolute: string;\n try {\n // Canonicalize both sides so a symlink that points outside the project\n // is resolved to its real target before the containment check.\n projectDir = await realPath(cwd());\n absolute = await realPath(resolve(projectDir, directory));\n } catch {\n return { error: `Directory not found: ${directory}` };\n }\n if (!isWithin(projectDir, absolute)) {\n return { error: `Path escapes project directory: ${directory}` };\n }\n\n const entries = await readDir(absolute);\n\n let files = entries\n .filter((e) => e.isFile)\n .map((e) => e.name);\n\n if (extensions?.length) {\n files = files.filter((f) =>\n extensions.some((ext) => f.endsWith(ext))\n );\n }\n\n return { directory, files, count: files.length };\n },\n});\n",
"tools/read-file.ts": "import { tool } from \"veryfront/tool\";\nimport { defineSchema } from \"veryfront/schemas\";\nimport { cwd, readTextFile, realPath, resolve } from \"veryfront/fs\";\n\n/** True when `target` is the same as, or nested under, `root` (both canonical). */\nfunction isWithin(root: string, target: string): boolean {\n const r = root.replace(/\\\\/g, \"/\");\n const t = target.replace(/\\\\/g, \"/\");\n return t === r || t.startsWith(`${r}/`);\n}\n\nexport default tool({\n id: \"read-file\",\n description: \"Read the contents of a file in the project\",\n inputSchema: defineSchema((v) => v.object({\n path: v.string().describe(\"File path relative to the project root\"),\n }))(),\n execute: async ({ path }) => {\n let projectDir: string;\n let absolute: string;\n try {\n // Canonicalize both sides so a symlink that points outside the project\n // is resolved to its real target before the containment check.\n projectDir = await realPath(cwd());\n absolute = await realPath(resolve(projectDir, path));\n } catch {\n return { error: `File not found: ${path}` };\n }\n if (!isWithin(projectDir, absolute)) {\n return { error: `Path escapes project directory: ${path}` };\n }\n const content = await readTextFile(absolute);\n return { path, content };\n },\n});\n",
"tsconfig.json": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"ESNext\",\n \"moduleResolution\": \"bundler\",\n \"strict\": true,\n \"jsx\": \"react-jsx\",\n \"skipLibCheck\": true,\n \"esModuleInterop\": true,\n \"paths\": {\n \"@/*\": [\"./*\"]\n }\n },\n \"include\": [\"**/*.ts\", \"**/*.tsx\"],\n \"exclude\": [\"node_modules\"]\n}\n"
}
},
Expand Down
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "veryfront",
"version": "0.1.738",
"version": "0.1.739",
"license": "Apache-2.0",
"nodeModulesDir": "auto",
"minimumDependencyAge": "P2D",
Expand Down
33 changes: 20 additions & 13 deletions src/agent/ag-ui/sse-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,21 +271,28 @@ export async function parseAgUiSseResponse(
const reader = response.body.getReader();
let buffer = "";

while (true) {
const result = await reader.read();
if (result.done) {
const tail = decoder.decode();
if (tail.length > 0) {
rawChunks.push(tail);
buffer += tail;
// try/finally so an error/abort mid-read still releases the reader lock;
// otherwise the underlying ReadableStream stays locked and the response
// body leaks.
try {
while (true) {
const result = await reader.read();
if (result.done) {
const tail = decoder.decode();
if (tail.length > 0) {
rawChunks.push(tail);
buffer += tail;
}
break;
}
break;
}

const decoded = decoder.decode(result.value, { stream: true });
rawChunks.push(decoded);
buffer += decoded;
buffer = consumeSseBuffer(run, buffer, options, state);
const decoded = decoder.decode(result.value, { stream: true });
rawChunks.push(decoded);
buffer += decoded;
buffer = consumeSseBuffer(run, buffer, options, state);
}
} finally {
reader.releaseLock();
}
}

Expand Down
96 changes: 54 additions & 42 deletions src/agent/react/use-chat/streaming/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,40 +63,46 @@ export async function handleStreamingResponse(

let buffer = "";

while (true) {
const { done, value } = await reader.read();
if (done) break;

buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? ""; // last element may be incomplete

for (const line of lines) {
if (!line.startsWith("data: ") || !line.trim()) continue;

const data = line.slice(6);
try {
const raw: unknown = JSON.parse(data);
if (!raw || typeof raw !== "object") continue;
const parsed = raw as Record<string, unknown>;
processStreamEvent(parsed, state, callbacks, getBuildParts);
} catch (_) {
/* expected: skip malformed JSON in SSE stream */
// try/finally so a read error or a throwing event handler still releases
// the reader lock, otherwise the stream stays locked and leaks.
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;

buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? ""; // last element may be incomplete

for (const line of lines) {
if (!line.startsWith("data: ") || !line.trim()) continue;

const data = line.slice(6);
try {
const raw: unknown = JSON.parse(data);
if (!raw || typeof raw !== "object") continue;
const parsed = raw as Record<string, unknown>;
processStreamEvent(parsed, state, callbacks, getBuildParts);
} catch (_) {
/* expected: skip malformed JSON in SSE stream */
}
}
}
}

// Process any remaining buffered data
if (buffer.startsWith("data: ") && buffer.trim()) {
try {
const raw: unknown = JSON.parse(buffer.slice(6));
if (raw && typeof raw === "object") {
const parsed = raw as Record<string, unknown>;
processStreamEvent(parsed, state, callbacks, getBuildParts);
// Process any remaining buffered data
if (buffer.startsWith("data: ") && buffer.trim()) {
try {
const raw: unknown = JSON.parse(buffer.slice(6));
if (raw && typeof raw === "object") {
const parsed = raw as Record<string, unknown>;
processStreamEvent(parsed, state, callbacks, getBuildParts);
}
} catch {
// Skip invalid JSON
}
} catch {
// Skip invalid JSON
}
} finally {
reader.releaseLock();
}
}

Expand Down Expand Up @@ -124,22 +130,28 @@ export async function handleAgUiStreamingResponse(
}
};

while (true) {
const { done, value } = await reader.read();
if (done) break;
// try/finally so a read error or a throwing event handler still releases
// the reader lock, otherwise the stream stays locked and leaks.
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;

const decoded = decodeAgUiSseChunk(
decoderState,
decoder.decode(value, { stream: true }),
);
for (const event of decoded.events) {
processDecodedEvents(event.chatEvents);
}
}

const decoded = decodeAgUiSseChunk(
decoderState,
decoder.decode(value, { stream: true }),
);
for (const event of decoded.events) {
const flushed = flushAgUiSseChunk(decoderState);
for (const event of flushed.events) {
processDecodedEvents(event.chatEvents);
}
}

const flushed = flushAgUiSseChunk(decoderState);
for (const event of flushed.events) {
processDecodedEvents(event.chatEvents);
} finally {
reader.releaseLock();
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/fs/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const expectedRuntimeExports = [
"mkdir",
"readDir",
"readTextFile",
"realPath",
"remove",
"resolve",
"writeTextFile",
Expand All @@ -36,6 +37,7 @@ describe("fs/index.ts exports", () => {
assertEquals(fsModule.exists, compatFsModule.exists);
assertEquals(fsModule.remove, compatFsModule.remove);
assertEquals(fsModule.readDir, compatFsModule.readDir);
assertEquals(fsModule.realPath, compatFsModule.realPath);
assertEquals(fsModule.basename, pathModule.basename);
assertEquals(fsModule.dirname, pathModule.dirname);
assertEquals(fsModule.extname, pathModule.extname);
Expand Down
1 change: 1 addition & 0 deletions src/fs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export {
mkdir,
readDir,
readTextFile,
realPath,
remove,
writeTextFile,
} from "#veryfront/platform/compat/fs.ts";
Expand Down
10 changes: 6 additions & 4 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -663,10 +663,12 @@ export class MCPServer {
level as typeof MCPServer.LOG_LEVELS[number],
)
) {
return Promise.reject({
code: -32602,
message: `Invalid log level: ${level}. Valid levels: ${MCPServer.LOG_LEVELS.join(", ")}`,
});
return Promise.reject(
new JsonRpcError(
-32602,
`Invalid log level: ${level}. Valid levels: ${MCPServer.LOG_LEVELS.join(", ")}`,
),
);
}
this.logLevel = level as typeof MCPServer.LOG_LEVELS[number];
return Promise.resolve({});
Expand Down
Loading