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
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ export function GitBranchSelector(props: {
"composer-reasoning-trigger inline-flex h-8 min-w-0 max-w-[13rem] items-center gap-1 rounded-full border px-2 text-xs font-medium outline-hidden transition-colors",
noRepo
? "border-transparent bg-foreground/[0.04] text-muted-foreground"
: "border-emerald-300/25 bg-emerald-50/65 text-foreground hover:bg-emerald-50 dark:border-emerald-300/15 dark:bg-emerald-400/[0.08]",
: "border-emerald-300/25 bg-emerald-50/65 text-foreground hover:bg-emerald-50 dark:border-emerald-300/15 dark:bg-emerald-400/[0.08] dark:hover:bg-emerald-400/[0.13]",
"disabled:pointer-events-none disabled:opacity-45",
)}
title={visibleError || (!canWrite ? disabledMessage : "") || label}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,7 @@ export function WorkdirPickerModal(props: WorkdirPickerModalProps) {
) : null}
</div>

<div className="min-h-0 flex-1 overflow-auto rounded-xl border border-border/60 bg-muted/20 p-2">
<div className="workdir-picker-tree min-h-0 flex-1 overflow-auto rounded-xl border border-border/60 bg-muted/20 p-2">
<ControlledTreeEnvironment
items={items}
getItemTitle={(item) => item.data.label}
Expand Down
41 changes: 41 additions & 0 deletions crates/agent-gateway/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,47 @@ html[data-liveagent-webui="gateway"] .project-file-tree-panel-scroll {
scroll-padding-bottom: max(16px, env(safe-area-inset-bottom));
}

html[data-liveagent-webui="gateway"] .workdir-picker-tree {
--rct-color-tree-bg: transparent;
--rct-color-tree-focus-outline: transparent;
--rct-color-focustree-item-selected-bg: hsl(var(--accent));
--rct-color-focustree-item-selected-text: hsl(var(--accent-foreground));
--rct-color-focustree-item-hover-bg: hsl(var(--muted) / 0.72);
--rct-color-focustree-item-hover-text: hsl(var(--foreground));
--rct-color-focustree-item-active-bg: hsl(var(--muted));
--rct-color-focustree-item-active-text: hsl(var(--foreground));
--rct-color-focustree-item-focused-border: hsl(var(--ring) / 0.58);
--rct-color-focustree-item-draggingover-bg: hsl(var(--accent));
--rct-color-focustree-item-draggingover-color: hsl(var(--accent-foreground));
--rct-color-nonfocustree-item-selected-bg: hsl(var(--accent) / 0.78);
--rct-color-nonfocustree-item-selected-text: hsl(var(--accent-foreground));
--rct-color-nonfocustree-item-focused-border: hsl(var(--border));
--rct-color-search-highlight-bg: hsl(var(--primary) / 0.18);
--rct-color-arrow: hsl(var(--muted-foreground));
--rct-bar-color: hsl(var(--ring));
--rct-focus-outline: hsl(var(--ring));
}

html[data-liveagent-webui="gateway"] .workdir-picker-tree .rct-tree-root {
background: transparent;
color: hsl(var(--foreground));
font-family: inherit;
}

html[data-liveagent-webui="gateway"].dark .workdir-picker-tree {
--rct-color-focustree-item-selected-bg: hsl(var(--accent) / 0.92);
--rct-color-focustree-item-selected-text: hsl(var(--foreground));
--rct-color-focustree-item-hover-bg: hsl(var(--muted) / 0.68);
--rct-color-focustree-item-active-bg: hsl(var(--muted) / 0.86);
--rct-color-nonfocustree-item-selected-bg: hsl(var(--accent) / 0.72);
--rct-color-nonfocustree-item-selected-text: hsl(var(--foreground));
--rct-color-search-highlight-bg: hsl(var(--primary) / 0.24);
}

html[data-liveagent-webui="gateway"] .workdir-picker-tree .rct-tree-item-button {
min-width: 0;
}

html[data-liveagent-webui="gateway"] .project-tools-panel-tabs::-webkit-scrollbar {
display: none;
width: 0;
Expand Down
2 changes: 1 addition & 1 deletion crates/agent-gui/src/components/git/GitBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ export function GitBranchSelector(props: {
"composer-reasoning-trigger inline-flex h-8 min-w-0 max-w-[13rem] items-center gap-1 rounded-full border px-2 text-xs font-medium outline-hidden transition-colors",
noRepo
? "border-transparent bg-foreground/[0.04] text-muted-foreground"
: "border-emerald-300/25 bg-emerald-50/65 text-foreground hover:bg-emerald-50 dark:border-emerald-300/15 dark:bg-emerald-400/[0.08]",
: "border-emerald-300/25 bg-emerald-50/65 text-foreground hover:bg-emerald-50 dark:border-emerald-300/15 dark:bg-emerald-400/[0.08] dark:hover:bg-emerald-400/[0.13]",
"disabled:pointer-events-none disabled:opacity-45",
)}
title={visibleError || (!canWrite ? disabledMessage : "") || label}
Expand Down
10 changes: 8 additions & 2 deletions crates/agent-gui/src/lib/chat/runner/agentRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,8 @@ export function buildToolsSuffix(
}
if (canWrite) {
lines.push(
"- Read a file at least once before Write or Edit. If the file changes after that Read, the next Write/Edit is rejected — Read it again, then retry.",
"- Existing files: Read the full file before Write or Edit; stale writes are rejected, so re-Read if needed.",
"- New files: call Write with a file path that includes the filename and the full content; parent directories are created automatically.",
);
}
if (has("Read")) {
Expand All @@ -215,7 +216,7 @@ export function buildToolsSuffix(
}
if (has("Write")) {
lines.push(
"- Write creates a new file or fully overwrites an existing one. There is no append mode — to add content, Read first, then either Write the full new content or use Edit to insert.",
"- Write fully creates or overwrites one text file. Do not set `mode`. The path must include the intended filename, not just a directory.",
);
}
if (has("Edit")) {
Expand All @@ -239,6 +240,11 @@ export function buildToolsSuffix(
if (has("Bash")) {
const alts: string[] = [];
if (hasReadFamily) alts.push("read / list / search via `cat`, `ls`, `find`, `grep`, or `rg`");
if (has("Write")) {
alts.push(
"write / create files via heredocs, `tee`, `touch`, `cp`, or `mkdir` just to prepare a parent directory",
);
}
if (has("Delete")) alts.push("delete via `rm`, `rmdir`, `unlink`, or `find -delete`");
if (alts.length > 0) {
lines.push(
Expand Down
164 changes: 115 additions & 49 deletions crates/agent-gui/src/lib/tools/fsTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ function previewSnippet(input: string, maxChars = 500) {
return `${text.slice(0, maxChars)}\n...(${text.length} chars total)...`;
}

function childDisplayPath(parent: string, fileName: string) {
const base = parent && parent !== "." ? parent.replace(/\/+$/g, "") : "";
return base ? `${base}/${fileName}` : fileName;
}

function formatLineWindow(startLine: number, numLines: number, totalLines: number) {
if (totalLines === 0 || numLines === 0) return "empty";
const endLine = startLine + numLines - 1;
Expand Down Expand Up @@ -304,6 +309,53 @@ export function createFsTools(params: {
return `Write requires a full-file Read first for existing files: ${formatResolvedTarget(resolved)}. Retry with Read using the same path before rewriting. Do not use Bash for workspace or Skill file operations.`;
}

function buildWriteDirectoryPathMessage(resolved: ResolvedPath) {
const directoryPath = formatResolvedTarget(resolved);
const examplePath = childDisplayPath(directoryPath, "file.txt");
return [
`Write.path points to a directory, not a file: ${directoryPath}.`,
`Retry Write with the intended filename appended to that directory, for example path="${examplePath}" if file.txt is the file you mean.`,
"Write creates missing parent directories, but it does not choose a filename from a directory path.",
].join(" ");
}

type NormalizedWriteArgs = {
path: unknown;
content: string;
hasContentArgument: boolean;
mode: "rewrite";
};

function normalizeWriteArgs(args: unknown): NormalizedWriteArgs {
const record =
args && typeof args === "object" && !Array.isArray(args)
? (args as Record<string, unknown>)
: {};
assertKnownArguments("Write", record, ["path", "content", "mode"]);
const mode = record.mode;
if (mode !== undefined && mode !== null && mode !== "" && mode !== "rewrite") {
throw new Error("Write.mode is deprecated. Omit it, or pass rewrite for compatibility.");
}
if ("content" in record && typeof record.content !== "string") {
throw new Error("Write.content must be a string.");
}
return {
path: record.path,
content: typeof record.content === "string" ? record.content : "",
hasContentArgument: "content" in record,
mode: "rewrite",
};
}

async function resolveWriteTarget(writeArgs: NormalizedWriteArgs) {
const resolved = await pathResolver.resolvePath(writeArgs.path, {
label: "Write.path",
intent: "write",
required: true,
});
return { resolved, path: backendPath(resolved) };
}

async function readPathStatus(resolved: ResolvedPath, path: string) {
return invokePathFileCommand<PathStatusCommandResponse>({
toolName: "Write",
Expand All @@ -316,16 +368,23 @@ export function createFsTools(params: {
});
}

function completeWriteToolCallForPreflight(toolCall: ToolCall): ToolCall {
const args = toolCall.arguments && typeof toolCall.arguments === "object"
? toolCall.arguments
: {};
if (typeof args.content === "string") return toolCall;
function completeWriteToolCallForPreflight(
toolCall: ToolCall,
normalized?: { path?: string; content?: string },
): ToolCall {
const args =
toolCall.arguments && typeof toolCall.arguments === "object" ? toolCall.arguments : {};
return {
...toolCall,
arguments: {
...args,
content: "",
...(typeof normalized?.path === "string" ? { path: normalized.path } : {}),
content:
typeof normalized?.content === "string"
? normalized.content
: typeof args.content === "string"
? args.content
: "",
},
};
}
Expand Down Expand Up @@ -472,16 +531,17 @@ export function createFsTools(params: {
const toolWrite: Tool = {
name: "Write",
description:
"Create a new text file or fully rewrite an existing workspace or enabled Skill file. There is no append mode — to add content, Read the file first, then either Write the full new content or use Edit to insert. Existing files must have been Read first so the tool can validate version metadata and reject stale rewrites.",
"Create a new text file or fully overwrite an existing workspace or enabled Skill text file. Pass only `path` and `content`; do not set `mode`. For new files, `path` must include the intended filename, for example `notes/todo.txt`; Write creates missing parent directories but does not choose filenames from directory paths. Existing files must have been fully Read first so the tool can reject stale rewrites. Use Edit for small changes.",
parameters: strictToolParameters({
path: Type.String({
description:
'Required file path. Accepts workspace-relative paths, absolute paths inside the workspace, pathRef values, and skill://<enabled-skill>/... paths. External paths outside workspace/enabled Skills are rejected for writes.',
}),
content: Type.String({ description: "Entire text content to write" }),
mode: Type.Optional(
Type.Literal("rewrite", {
description: "Only rewrite is supported; append is intentionally disabled",
Type.Union([Type.Literal("rewrite"), Type.Literal("")], {
description:
"Deprecated compatibility field. Omit this argument; empty string and rewrite are accepted as rewrite.",
}),
),
}),
Expand Down Expand Up @@ -1355,38 +1415,38 @@ export function createFsTools(params: {
async function execWrite(args: any, signal?: AbortSignal): Promise<ToolOk<WriteResultDetails>> {
if (signal?.aborted) throw new Error("Cancelled");

const resolved = await pathResolver.resolvePath(args?.path, {
label: "Write.path",
intent: "write",
required: true,
});
const path = backendPath(resolved);
const writeArgs = normalizeWriteArgs(args);
const { resolved, path } = await resolveWriteTarget(writeArgs);
if (!path) throw new Error("Write.path must identify a file");
const content = typeof args?.content === "string" ? args.content : "";
const mode = typeof args?.mode === "string" ? args.mode : "rewrite";
if (mode !== "rewrite") {
throw new Error("Write.mode only supports rewrite");
}
const content = writeArgs.content;

const latest = fileState.getLatest(statePathKey(resolved));
if (latest?.kind === "text" && latest.isPartialView) {
throw new Error(buildWriteRequiresFullReadMessage(resolved));
}
const fullSnapshot = fileState.getLatestFullText(statePathKey(resolved));

const res = await invokePathFileCommand<WriteCommandResponse>({
toolName: "Write",
resolved,
command: "fs_write_text",
args: {
workdir: resolved.workdir,
path,
content,
mode: "rewrite",
expected_mtime_ms: fullSnapshot?.mtimeMs,
expected_content_hash: fullSnapshot?.contentHash,
},
});
let res: WriteCommandResponse;
try {
res = await invokePathFileCommand<WriteCommandResponse>({
toolName: "Write",
resolved,
command: "fs_write_text",
args: {
workdir: resolved.workdir,
path,
content,
mode: "rewrite",
expected_mtime_ms: fullSnapshot?.mtimeMs,
expected_content_hash: fullSnapshot?.contentHash,
},
});
} catch (error) {
if (/Cannot write to a directory path/i.test(asErrorMessage(error))) {
throw new Error(buildWriteDirectoryPathMessage(resolved));
}
throw error;
}

const details: WriteResultDetails = {
kind: "write",
Expand All @@ -1410,11 +1470,9 @@ export function createFsTools(params: {
content: [
{
type: "text",
text:
`Write: ${formatResolvedTarget(resolved)}\n` +
`mode=rewrite\n` +
`target=${details.existedBefore ? "existing" : "new"}\n` +
`bytesWritten=${details.bytesWritten}`,
text: details.existedBefore
? `File updated successfully at: ${formatResolvedTarget(resolved)}`
: `File created successfully at: ${formatResolvedTarget(resolved)}`,
},
],
details,
Expand All @@ -1427,18 +1485,16 @@ export function createFsTools(params: {
): Promise<BuiltinToolPreflightResult | null> {
if (signal?.aborted) return null;

const rawPath = typeof toolCall.arguments?.path === "string" ? toolCall.arguments.path : "";
const writeArgs = normalizeWriteArgs(toolCall.arguments);
const rawPath = typeof writeArgs.path === "string" ? writeArgs.path : "";
if (!rawPath.trim()) return null;

const resolved = await pathResolver.resolvePath(rawPath, {
label: "Write.path",
intent: "write",
required: true,
});
const path = backendPath(resolved);
const { resolved, path } = await resolveWriteTarget(writeArgs);
if (!path) {
return {
toolCall: completeWriteToolCallForPreflight(toolCall),
toolCall: completeWriteToolCallForPreflight(toolCall, {
content: writeArgs.content,
}),
toolResult: buildToolErrorResult(toolCall, "Write.path must identify a file"),
};
}
Expand All @@ -1452,7 +1508,10 @@ export function createFsTools(params: {
const latest = fileState.getLatest(key);
if (latest?.kind === "text" && latest.isPartialView) {
return {
toolCall: completeWriteToolCallForPreflight(toolCall),
toolCall: completeWriteToolCallForPreflight(toolCall, {
path: resolved.displayPath,
content: writeArgs.content,
}),
toolResult: buildToolErrorResult(toolCall, buildWriteRequiresFullReadMessage(resolved)),
};
}
Expand All @@ -1468,11 +1527,18 @@ export function createFsTools(params: {
return null;
}

const completedToolCall = completeWriteToolCallForPreflight(toolCall);
const completedToolCall = completeWriteToolCallForPreflight(toolCall, {
path: resolved.displayPath,
content: writeArgs.content,
});
if (status.kind === "dir") {
if (!writeArgs.hasContentArgument) return null;
return {
toolCall: completedToolCall,
toolResult: buildToolErrorResult(toolCall, "Cannot write to a directory path"),
toolResult: buildToolErrorResult(
toolCall,
buildWriteDirectoryPathMessage(resolved),
),
};
}

Expand Down
14 changes: 14 additions & 0 deletions crates/agent-gui/test/chat/markdown-image-policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,20 @@ test("agent tool rules keep local file discovery on file tools instead of Bash",
assert.match(suffix, /Do not run Bash cat\/ls\/find\/grep/);
});

test("agent tool rules steer new files to concrete Write paths", () => {
const suffix = agentRunnerModule.buildToolsSuffix("/workspace", [
"Read",
"Write",
"Edit",
"Bash",
]);
assert.match(suffix, /New files: call Write with a file path that includes the filename and the full content/);
assert.match(suffix, /parent directories are created automatically/);
assert.match(suffix, /Do not set `mode`/);
assert.match(suffix, /path must include the intended filename, not just a directory/);
assert.match(suffix, /write \/ create files via heredocs, `tee`, `touch`, `cp`, or `mkdir`/);
});

test("agent tool rules keep workspace and Skills deletion on Delete", () => {
const suffix = agentRunnerModule.buildToolsSuffix("/workspace", [
"Delete",
Expand Down
Loading
Loading