From 31e35e00b893922cf424c6ea979c988f9d4dfa72 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Mon, 25 May 2026 16:21:40 +0530 Subject: [PATCH] Refactor builtin file tools beyond workspace scope Replace workspace-scoped builtin file tools with general-purpose file-* tools that accept relative, absolute, and ~/ paths. Relative paths still resolve against the configured workdir. File operations within the workdir are auto-approved. File operations outside the workdir now emit file permission metadata and require user approval, with support for once, session, and persistent grants. Add a shared filesystem layer for text-focused read/write/edit/list/search operations, including binary-file safeguards for text reads. parseFile and LLMParse continue to read file buffers for document/image parsing. Update copilot prompts, background/live-note agents, knowledge workflows, and renderer labels/UI to use the new file-* tool surface and permission details. Add package-local Vitest setup for @x/core with colocated filesystem unit tests covering path resolution, canonical permission paths, binary detection, read/write/edit behavior, glob, and grep. --- apps/x/LIVE_NOTE.md | 12 +- apps/x/apps/renderer/src/App.tsx | 1 + .../ai-elements/permission-request.tsx | 48 +- .../renderer/src/lib/chat-conversation.ts | 26 +- apps/x/packages/core/package.json | 11 +- apps/x/packages/core/src/agents/runtime.ts | 171 ++++- .../src/application/assistant/instructions.ts | 68 +- .../assistant/skills/app-navigation/skill.ts | 2 +- .../assistant/skills/background-task/skill.ts | 4 +- .../assistant/skills/builtin-tools/skill.ts | 30 +- .../skills/create-presentations/skill.ts | 16 +- .../assistant/skills/doc-collab/skill.ts | 32 +- .../assistant/skills/draft-emails/skill.ts | 12 +- .../assistant/skills/live-note/skill.ts | 24 +- .../assistant/skills/mcp-integration/skill.ts | 2 +- .../assistant/skills/meeting-prep/skill.ts | 18 +- .../assistant/skills/notify-user/skill.ts | 2 +- .../core/src/application/lib/builtin-tools.ts | 409 +++-------- .../core/src/background-tasks/agent.ts | 4 +- .../core/src/background-tasks/runner.ts | 2 +- apps/x/packages/core/src/config/security.ts | 127 +++- .../core/src/filesystem/files.test.ts | 204 ++++++ apps/x/packages/core/src/filesystem/files.ts | 641 ++++++++++++++++++ .../core/src/knowledge/agent_notes_agent.ts | 20 +- .../core/src/knowledge/build_graph.ts | 8 +- .../core/src/knowledge/inline_task_agent.ts | 16 +- .../core/src/knowledge/label_emails.ts | 6 +- .../core/src/knowledge/labeling_agent.ts | 14 +- .../core/src/knowledge/live-note/agent.ts | 22 +- .../core/src/knowledge/live-note/runner.ts | 8 +- .../core/src/knowledge/note_creation.ts | 78 +-- .../core/src/knowledge/note_tagging_agent.ts | 14 +- .../packages/core/src/knowledge/tag_notes.ts | 6 +- .../core/src/pre_built/email-draft.md | 26 +- .../core/src/pre_built/meeting-prep.md | 26 +- apps/x/packages/core/src/runs/runs.ts | 9 +- apps/x/packages/core/tsconfig.build.json | 7 + apps/x/packages/core/vitest.config.ts | 11 + apps/x/packages/shared/src/runs.ts | 14 + apps/x/pnpm-lock.yaml | 238 +++++++ apps/x/pnpm-workspace.yaml | 3 + 41 files changed, 1777 insertions(+), 615 deletions(-) create mode 100644 apps/x/packages/core/src/filesystem/files.test.ts create mode 100644 apps/x/packages/core/src/filesystem/files.ts create mode 100644 apps/x/packages/core/tsconfig.build.json create mode 100644 apps/x/packages/core/vitest.config.ts diff --git a/apps/x/LIVE_NOTE.md b/apps/x/LIVE_NOTE.md index fe31d019..d8a157d7 100644 --- a/apps/x/LIVE_NOTE.md +++ b/apps/x/LIVE_NOTE.md @@ -70,7 +70,7 @@ The `once` trigger from the prior model has been **dropped** — it didn't fit t Two paths, both producing identical on-disk YAML: 1. **Hand-written** — type the `live:` block directly into the note's frontmatter. The scheduler picks it up on its next 15-second tick. -2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond "live" or "track" (see "Prompts Catalog → Copilot trigger paragraph"); it loads the `live-note` skill, edits the frontmatter via `workspace-edit`, then **runs the agent once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet. +2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond "live" or "track" (see "Prompts Catalog → Copilot trigger paragraph"); it loads the `live-note` skill, edits the frontmatter via `file-editText`, then **runs the agent once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet. When the note is **already live** and the user asks to track something new, Copilot extends the existing `live.objective` in natural-language prose. It does not create a second `live:` block. @@ -92,8 +92,8 @@ When a trigger fires, the live-note agent receives a short message: - For event runs only: the matching `eventMatchCriteria` text and the event payload, with a Pass-2 decision directive ("only edit if the event genuinely warrants it"). The agent's system prompt tells it to: -1. Call `workspace-readFile` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh). -2. Make small, **patch-style** edits with `workspace-edit` — change one region, re-read, change the next region — rather than one-shot rewrites. +1. Call `file-readText` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh). +2. Make small, **patch-style** edits with `file-editText` — change one region, re-read, change the next region — rather than one-shot rewrites. 3. Follow default body structure unless the objective overrides: H1 stays the title; a 1-3 sentence rolling summary at the top; H2 sub-topic sections below, freshest first. 4. Never modify YAML frontmatter — that's owned by the user and the runtime. 5. End with a 1-2 sentence summary stored as `lastRunSummary`. @@ -115,7 +115,7 @@ Backend (main process) ├─ Event processor (5 s) ──┼──► runLiveNoteAgent() ──► live-note-agent └─ Builtin tool │ │ run-live-note-agent ────┘ ▼ - workspace-readFile / -edit + file-readText / -edit │ ▼ body region(s) rewritten on disk @@ -249,7 +249,7 @@ The contract (defined in the run-agent system prompt — `packages/core/src/know - Then content organized by sub-topic under H2 headings, freshest/most-important first. - Tightness over decoration. - **Override** — if the objective specifies a different layout (e.g. "show the top 5 stories at the top, with a one-paragraph summary above them"), follow that exactly. -- **Patch-style updates** — make small, incremental `workspace-edit` calls (read → edit one region → re-read → next), not one-shot whole-body rewrites. This preserves user-added content the agent didn't account for and keeps diffs reviewable. +- **Patch-style updates** — make small, incremental `file-editText` calls (read → edit one region → re-read → next), not one-shot whole-body rewrites. This preserves user-added content the agent didn't account for and keeps diffs reviewable. - **Boundaries**: never modify the frontmatter; the agent is the sole writer of the body below the H1. --- @@ -316,7 +316,7 @@ Every LLM-facing prompt in the feature, with file pointers. After any edit: `cd - **Purpose**: the user message seeded into each agent run. - **File**: `packages/core/src/knowledge/live-note/runner.ts` (`buildMessage`). - **Inputs**: `filePath` (presented as `knowledge/${filePath}` in the message), `live.objective`, `live.triggers?.eventMatchCriteria` (only on event runs), `trigger`, optional `context`, plus `localNow` / `tz`. -- **Behavior**: tells the agent to call `workspace-readFile` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run) and to make patch-style edits. +- **Behavior**: tells the agent to call `file-readText` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run) and to make patch-style edits. Three branches by `trigger`: - **`manual`** — base message. If `context` is passed, it's appended as a `**Context:**` section. The `run-live-note-agent` tool uses this path for both plain refreshes and context-biased backfills. diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 33eda548..080ddf58 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5677,6 +5677,7 @@ function App() { {rendered} handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} diff --git a/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx b/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx index e9cef6dc..c0369a9a 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx @@ -12,6 +12,7 @@ import { cn } from "@/lib/utils"; import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, XCircleIcon, XIcon } from "lucide-react"; import type { ComponentProps } from "react"; import { ToolCallPart } from "@x/shared/dist/message.js"; +import { ToolPermissionMetadata } from "@x/shared/dist/runs.js"; import z from "zod"; export type PermissionRequestProps = ComponentProps<"div"> & { @@ -22,6 +23,15 @@ export type PermissionRequestProps = ComponentProps<"div"> & { onDeny?: () => void; isProcessing?: boolean; response?: 'approve' | 'deny' | null; + permission?: z.infer; +}; + +const fileActionLabels: Record = { + read: "Read file", + list: "List folder", + search: "Search files", + write: "Write files", + delete: "Delete path", }; export const PermissionRequest = ({ @@ -33,14 +43,16 @@ export const PermissionRequest = ({ onDeny, isProcessing = false, response = null, + permission, ...props }: PermissionRequestProps) => { // Extract command from arguments if it's executeCommand - const command = toolCall.toolName === "executeCommand" + const command = permission?.kind === "command" || toolCall.toolName === "executeCommand" ? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments ? String(toolCall.arguments.command) : JSON.stringify(toolCall.arguments)) : null; + const filePermission = permission?.kind === "file" ? permission : null; const isResponded = response !== null; const isApproved = response === 'approve'; @@ -113,7 +125,35 @@ export const PermissionRequest = ({ )} - {!command && toolCall.arguments && ( + {filePermission && ( +
+
+

+ Action +

+

+ {fileActionLabels[filePermission.operation] ?? filePermission.operation} +

+
+
+

+ Path{filePermission.paths.length === 1 ? "" : "s"} +

+
+                    {filePermission.paths.join("\n")}
+                  
+
+
+

+ Approval Scope +

+
+                    {filePermission.pathPrefix}
+                  
+
+
+ )} + {!command && !filePermission && toolCall.arguments && (

Arguments @@ -133,12 +173,12 @@ export const PermissionRequest = ({ size="sm" onClick={onApprove} disabled={isProcessing} - className={cn("flex-1", command && "rounded-r-none")} + className={cn("flex-1", (command || filePermission) && "rounded-r-none")} > Approve - {command && ( + {(command || filePermission) && (