From 80ad689be79f591e82712c8a538e179fd8f4b1de Mon Sep 17 00:00:00 2001 From: zaixiaziyang <62634142+zaixiaziyang@users.noreply.github.com> Date: Fri, 17 Jul 2026 03:36:13 +0800 Subject: [PATCH 1/2] feat(agent): enforce readonly execution mode --- .../agent-gateway/internal/handler/types.go | 2 +- .../internal/handler/types_test.go | 1 + .../test/webui/web-settings.test.mjs | 12 ++ .../agent-gateway/web/src/app/GatewayApp.tsx | 10 ++ crates/agent-gateway/web/src/i18n/config.ts | 13 ++ .../web/src/lib/settings/index.ts | 7 +- .../web/src/pages/chat/ChatComposerBar.tsx | 68 ++++++++++- .../src/pages/settings/SystemSettingsForm.tsx | 81 +++++++++---- .../src/components/cron/CronPromptRunner.tsx | 15 ++- crates/agent-gui/src/i18n/config.ts | 13 ++ crates/agent-gui/src/lib/settings/index.ts | 7 +- .../agent-gui/src/lib/subagents/agentTool.ts | 112 ++++++++++++++++-- crates/agent-gui/src/lib/subagents/errors.ts | 1 + .../src/lib/tools/builtinRegistry.ts | 1 + .../src/lib/tools/toolAccessPolicy.ts | 77 ++++++++++++ crates/agent-gui/src/pages/ChatPage.tsx | 29 +++-- .../pages/chat/components/ChatComposerBar.tsx | 68 ++++++++++- .../pages/chat/gateway/gatewayBridgeTypes.ts | 1 + .../chat/turns/runAgentConversationTurn.ts | 28 ++++- .../src/pages/settings/SystemSettingsForm.tsx | 81 +++++++++---- .../test/settings/normalization.test.mjs | 12 ++ .../test/subagents/agent-tool.test.mjs | 53 +++++++++ crates/agent-gui/test/subagents/harness.mjs | 1 + .../test/tools/tool-access-policy.test.mjs | 94 +++++++++++++++ docs/architecture/gui.md | 3 +- docs/features/chat-runtime.md | 9 +- docs/features/tools.md | 11 ++ 27 files changed, 736 insertions(+), 74 deletions(-) create mode 100644 crates/agent-gui/src/lib/tools/toolAccessPolicy.ts create mode 100644 crates/agent-gui/test/tools/tool-access-policy.test.mjs diff --git a/crates/agent-gateway/internal/handler/types.go b/crates/agent-gateway/internal/handler/types.go index b85ebf930..4182dc3e6 100644 --- a/crates/agent-gateway/internal/handler/types.go +++ b/crates/agent-gateway/internal/handler/types.go @@ -137,7 +137,7 @@ func normalizeTrimmedText(value string) string { func NormalizeExecutionMode(value string) string { normalized := normalizeTrimmedText(value) switch normalized { - case "tools", "agent-dev": + case "tools", "agent-dev", "readonly": return normalized default: return "text" diff --git a/crates/agent-gateway/internal/handler/types_test.go b/crates/agent-gateway/internal/handler/types_test.go index 9b521cdfc..67cc325d5 100644 --- a/crates/agent-gateway/internal/handler/types_test.go +++ b/crates/agent-gateway/internal/handler/types_test.go @@ -11,6 +11,7 @@ func TestNormalizeExecutionMode(t *testing.T) { cases := map[string]string{ "": "text", " text ": "text", + "readonly": "readonly", "tools": "tools", "agent-dev": "agent-dev", "unknown": "text", diff --git a/crates/agent-gateway/test/webui/web-settings.test.mjs b/crates/agent-gateway/test/webui/web-settings.test.mjs index 54749b0bc..525f6d21a 100644 --- a/crates/agent-gateway/test/webui/web-settings.test.mjs +++ b/crates/agent-gateway/test/webui/web-settings.test.mjs @@ -1466,3 +1466,15 @@ test("web right dock migrates the legacy tabs shape", () => { assert.deepEqual(project.tabOrder, ["sess-1", RIGHT_DOCK_TAB_IDS.fileTree]); assert.equal(project.activeTabId, "sess-1"); }); + +test("web settings preserve readonly execution mode", () => { + installWindow(); + const defaults = settings.getDefaultSettings(); + const normalized = settings.normalizeSettings({ + system: { ...defaults.system, executionMode: "readonly" }, + }); + + assert.equal(normalized.system.executionMode, "readonly"); + assert.equal(settings.isAgentExecutionMode("readonly"), true); + assert.equal(settings.isReadOnlyExecutionMode("readonly"), true); +}); diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 44d6bb62d..3134e1dce 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -66,6 +66,7 @@ import { toModelValue } from "@/lib/providers/llm"; import { type ChatRuntimeControls, DEFAULT_WORKSPACE_PROJECT_ID, + type ExecutionMode, findProviderModelConfig, getChatRuntimeReasoningLevelsForProvider, getNextTheme, @@ -92,6 +93,7 @@ import { updateRightDockWidth, updateSkills, updateSshProjectHostIds, + updateSystem, type WorkspaceProject, workspaceProjectPathKey, } from "@/lib/settings"; @@ -3397,6 +3399,12 @@ export default function GatewayApp() { activeSelectedModel?.model, ], ); + const handleExecutionModeChange = useCallback( + (executionMode: ExecutionMode) => { + setSettings((prev) => updateSystem(prev, { executionMode })); + }, + [setSettings], + ); const isAgentDevExecutionMode = isAgentDevMode(settings.system.executionMode); const modelOptions = useMemo( @@ -4226,6 +4234,8 @@ export default function GatewayApp() { workdir={displayedConversationWorkdir} enabledSkills={enabledComposerSkills} isAgentMode={isAgentMode} + executionMode={settings.system.executionMode} + onExecutionModeChange={handleExecutionModeChange} chatRuntimeControls={chatRuntimeControlsForCurrentProvider} reasoningOptions={chatRuntimeReasoningOptions} thinkingAlwaysOn={chatRuntimeThinkingAlwaysOn} diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index c360ff574..301d4cd96 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -1029,13 +1029,19 @@ export const translations: Record> = { "settings.fontSizeLarge": "大", "settings.fontSizeXLarge": "特大", "settings.executionMode": "执行模式", + "settings.executionModeDesc": + "选择新对话的默认权限;当前对话也可从输入框切换。", "settings.chatMode": "Chat 模式", "settings.chatModeDesc": "纯文本对话,模型只输出文本与 Markdown", + "settings.readonlyAgentMode": "只读 Agent", + "settings.readonlyAgentModeDesc": "可检查项目并委派只读代理,不修改文件或外部系统", "settings.agentMode": "Agent 模式", "settings.agentModeDesc": "允许调用工具,执行文件与命令行操作", "settings.agentDevMode": "Agent dev 模式", "settings.agentDevModeDesc": "与 Agent 模式一致,但会把当前对话的流式请求与返回逐行写入 ~/.liveagent/debug/<对话ID>.jsonl", + "settings.agentTrace": "Agent 调试记录", + "settings.agentTraceDesc": "仅在 Agent 模式启用时写入逐行 JSONL 调试记录", "settings.workdir": "项目文件夹", "settings.workdirRequired": "必填", "settings.workdirDesc": "选择这个项目的文件夹。文件工具会在所选项目目录内读写/搜索。", @@ -2814,13 +2820,20 @@ export const translations: Record> = { "settings.fontSizeLarge": "Large", "settings.fontSizeXLarge": "Extra Large", "settings.executionMode": "Execution Mode", + "settings.executionModeDesc": + "Choose the default permission for new conversations. The current conversation can also be changed from the main composer.", "settings.chatMode": "Chat Mode", "settings.chatModeDesc": "Plain text conversation, model outputs text and Markdown only", + "settings.readonlyAgentMode": "Read-only Agent", + "settings.readonlyAgentModeDesc": + "Can inspect the project and delegate read-only agents without modifying files or external systems", "settings.agentMode": "Agent Mode", "settings.agentModeDesc": "Allows tool calls, file and command-line operations", "settings.agentDevMode": "Agent Dev Mode", "settings.agentDevModeDesc": "Same as Agent mode, but also writes each streaming request and response line-by-line to ~/.liveagent/debug/.jsonl", + "settings.agentTrace": "Agent debug trace", + "settings.agentTraceDesc": "Writes line-delimited JSONL traces only while Agent mode is active", "settings.workdir": "Project Folder", "settings.workdirRequired": "Required", "settings.workdirDesc": diff --git a/crates/agent-gateway/web/src/lib/settings/index.ts b/crates/agent-gateway/web/src/lib/settings/index.ts index 49f81cba1..b3ac8a714 100644 --- a/crates/agent-gateway/web/src/lib/settings/index.ts +++ b/crates/agent-gateway/web/src/lib/settings/index.ts @@ -11,7 +11,7 @@ export type { SystemToolId } from "../tools/systemToolOptions"; export type ProviderId = "codex" | "claude_code" | "gemini"; -export type ExecutionMode = "text" | "tools" | "agent-dev"; +export type ExecutionMode = "text" | "readonly" | "tools" | "agent-dev"; export type CodexRequestFormat = "openai-completions" | "openai-responses"; @@ -408,6 +408,7 @@ export function getBuiltinCustomProviders(): CustomProvider[] { function normalizeExecutionMode(input: unknown): ExecutionMode { switch (input) { case "text": + case "readonly": case "tools": case "agent-dev": return input; @@ -420,6 +421,10 @@ export function isAgentExecutionMode(mode: ExecutionMode): boolean { return mode !== "text"; } +export function isReadOnlyExecutionMode(mode: ExecutionMode): boolean { + return mode === "readonly"; +} + export function isAgentDevMode(mode: ExecutionMode): boolean { return mode === "agent-dev"; } diff --git a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx index fd059159d..043fd6868 100644 --- a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx +++ b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx @@ -24,13 +24,16 @@ import { Lightbulb, LightbulbOff, Loader2, + MessageSquare, Paperclip, Play, Send, + Shield, Sparkle, Square, SquarePen, Trash2, + Wrench, X, } from "../../components/icons"; import { Button } from "../../components/ui/button"; @@ -47,6 +50,7 @@ import type { GitClient } from "../../lib/git/types"; import { type ChatRuntimeControls, DEFAULT_CHAT_RUNTIME_CONTROLS, + type ExecutionMode, type ReasoningLevel, } from "../../lib/settings"; import { cn } from "../../lib/shared/utils"; @@ -115,6 +119,8 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { workdir: string; enabledSkills: MentionComposerSkill[]; isAgentMode: boolean; + executionMode: ExecutionMode; + onExecutionModeChange: (mode: ExecutionMode) => void; chatRuntimeControls: ChatRuntimeControls; reasoningOptions: ReasoningLevel[]; thinkingAlwaysOn: boolean; @@ -148,6 +154,8 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { workdir, enabledSkills, isAgentMode, + executionMode, + onExecutionModeChange, chatRuntimeControls, reasoningOptions, thinkingAlwaysOn, @@ -187,7 +195,9 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { const [queueScrollbar, setQueueScrollbar] = useState( DEFAULT_QUEUE_SCROLLBAR_STATE, ); - const uploadDisabled = isInputDisabled || isUploadingFiles || !isAgentMode || !workdir; + const visibleExecutionMode = executionMode === "agent-dev" ? "tools" : executionMode; + const uploadDisabled = + isInputDisabled || isUploadingFiles || visibleExecutionMode !== "tools" || !workdir; const controlsDisabled = isInputDisabled; const hasSendableDraft = !composerIsEmpty || pendingUploadedFiles.length > 0; const thinkingSupported = reasoningOptions.length > 0; @@ -213,6 +223,18 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { : t("chat.runtime.thinkingTooltip"); const webSearchTooltip = t("chat.runtime.webSearchTooltip"); const toggleQueueTooltip = queueCollapsed ? t("chat.queue.expand") : t("chat.queue.collapse"); + const executionModes: Array<{ + mode: "text" | "readonly" | "tools"; + label: string; + icon: typeof MessageSquare; + }> = [ + { mode: "text", label: t("settings.chatMode"), icon: MessageSquare }, + { mode: "readonly", label: t("settings.readonlyAgentMode"), icon: Shield }, + { mode: "tools", label: t("settings.agentMode"), icon: Wrench }, + ]; + const selectedExecutionMode = + executionModes.find((option) => option.mode === visibleExecutionMode) ?? executionModes[2]; + const SelectedExecutionModeIcon = selectedExecutionMode.icon; const toggleQueueCollapsed = useCallback(() => { setQueueCollapsed((current) => !current); @@ -620,6 +642,50 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
+ + @@ -184,38 +187,76 @@ export function SystemSettingsForm(props: SettingsSectionProps) {
+ + {isClassicAgentMode ? ( + + ) : null}
diff --git a/crates/agent-gui/src/components/cron/CronPromptRunner.tsx b/crates/agent-gui/src/components/cron/CronPromptRunner.tsx index 00b15be54..eb506c024 100644 --- a/crates/agent-gui/src/components/cron/CronPromptRunner.tsx +++ b/crates/agent-gui/src/components/cron/CronPromptRunner.tsx @@ -13,6 +13,7 @@ import { findProviderModelConfig, isAgentDevMode, isAgentExecutionMode, + isReadOnlyExecutionMode, } from "../../lib/settings"; import { buildSkillsSystemPrompt, @@ -23,6 +24,10 @@ import { import { buildBuiltinToolRegistry } from "../../lib/tools/builtinRegistry"; import { createFileToolState } from "../../lib/tools/fileToolState"; import type { SkillAccessPolicy } from "../../lib/tools/skillAccessPolicy"; +import { + restrictBuiltinToolRegistry, + toolAccessModeForExecutionMode, +} from "../../lib/tools/toolAccessPolicy"; import { appendSystemPrompt } from "../../pages/chat"; import { createCompletePromptRunInput, @@ -107,7 +112,7 @@ async function executeCronPromptRun( ) { if (!isAgentExecutionMode(settings.system.executionMode)) { throw new Error( - "Auto Prompt requires System -> Execution Mode to be Agent Mode or Agent Dev Mode.", + "Auto Prompt requires System -> Execution Mode to be Read-only Agent or Agent Mode.", ); } @@ -132,7 +137,8 @@ async function executeCronPromptRun( const skillsContext = await buildCronSkillsContext(settings); const activeAgentPrompt = getActiveAgentPrompt(settings); const runtimePlatform = await resolveRuntimePlatform(); - const builtinRegistry = await buildBuiltinToolRegistry({ + const readOnlyMode = isReadOnlyExecutionMode(settings.system.executionMode); + const unrestrictedRegistry = await buildBuiltinToolRegistry({ workdir, providerId: provider.type, runtimePlatform, @@ -147,8 +153,13 @@ async function executeCronPromptRun( }, selectedSystemToolIds: settings.system.selectedSystemTools, getMcpSettings: () => settings.mcp, + memoryToolMode: readOnlyMode ? "ro" : "rw", mcpLoadFailureMode: "throw", }); + const builtinRegistry = restrictBuiltinToolRegistry( + unrestrictedRegistry, + toolAccessModeForExecutionMode(settings.system.executionMode), + ); let systemPrompt = buildCronSystemPrompt(request.taskName); if (activeAgentPrompt) { diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 18d4b5064..bc355930c 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -1102,13 +1102,19 @@ export const translations: Record> = { "settings.fontSizeLarge": "大", "settings.fontSizeXLarge": "特大", "settings.executionMode": "执行模式", + "settings.executionModeDesc": + "选择新对话的默认权限;当前对话也可从输入框切换。", "settings.chatMode": "Chat 模式", "settings.chatModeDesc": "纯文本对话,模型只输出文本与 Markdown", + "settings.readonlyAgentMode": "只读 Agent", + "settings.readonlyAgentModeDesc": "可检查项目并委派只读代理,不修改文件或外部系统", "settings.agentMode": "Agent 模式", "settings.agentModeDesc": "允许调用工具,执行文件与命令行操作", "settings.agentDevMode": "Agent dev 模式", "settings.agentDevModeDesc": "与 Agent 模式一致,但会把当前对话的流式请求与返回逐行写入 ~/.liveagent/debug/<对话ID>.jsonl", + "settings.agentTrace": "Agent 调试记录", + "settings.agentTraceDesc": "仅在 Agent 模式启用时写入逐行 JSONL 调试记录", "settings.workdir": "项目文件夹", "settings.workdirRequired": "必填", "settings.workdirDesc": "Agent 模式下文件工具使用当前项目文件夹作为根目录。", @@ -2959,13 +2965,20 @@ export const translations: Record> = { "settings.fontSizeLarge": "Large", "settings.fontSizeXLarge": "Extra Large", "settings.executionMode": "Execution Mode", + "settings.executionModeDesc": + "Choose the default permission for new conversations. The current conversation can also be changed from the main composer.", "settings.chatMode": "Chat Mode", "settings.chatModeDesc": "Plain text conversation, model outputs text and Markdown only", + "settings.readonlyAgentMode": "Read-only Agent", + "settings.readonlyAgentModeDesc": + "Can inspect the project and delegate read-only agents without modifying files or external systems", "settings.agentMode": "Agent Mode", "settings.agentModeDesc": "Allows tool calls, file and command-line operations", "settings.agentDevMode": "Agent Dev Mode", "settings.agentDevModeDesc": "Same as Agent mode, but also writes each streaming request and response line-by-line to ~/.liveagent/debug/.jsonl", + "settings.agentTrace": "Agent debug trace", + "settings.agentTraceDesc": "Writes line-delimited JSONL traces only while Agent mode is active", "settings.workdir": "Project Folder", "settings.workdirRequired": "Required", "settings.workdirDesc": diff --git a/crates/agent-gui/src/lib/settings/index.ts b/crates/agent-gui/src/lib/settings/index.ts index a2c0a41b2..219e219f3 100644 --- a/crates/agent-gui/src/lib/settings/index.ts +++ b/crates/agent-gui/src/lib/settings/index.ts @@ -19,7 +19,7 @@ export type { SystemToolId } from "../tools/systemToolOptions"; export type ProviderId = "codex" | "claude_code" | "gemini"; -export type ExecutionMode = "text" | "tools" | "agent-dev"; +export type ExecutionMode = "text" | "readonly" | "tools" | "agent-dev"; export type CodexRequestFormat = "openai-completions" | "openai-responses"; @@ -428,6 +428,7 @@ export function getBuiltinCustomProviders(): CustomProvider[] { function normalizeExecutionMode(input: unknown): ExecutionMode { switch (input) { case "text": + case "readonly": case "tools": case "agent-dev": return input; @@ -440,6 +441,10 @@ export function isAgentExecutionMode(mode: ExecutionMode): boolean { return mode !== "text"; } +export function isReadOnlyExecutionMode(mode: ExecutionMode): boolean { + return mode === "readonly"; +} + export function isAgentDevMode(mode: ExecutionMode): boolean { return mode === "agent-dev"; } diff --git a/crates/agent-gui/src/lib/subagents/agentTool.ts b/crates/agent-gui/src/lib/subagents/agentTool.ts index a34533cf5..0cfa5573f 100644 --- a/crates/agent-gui/src/lib/subagents/agentTool.ts +++ b/crates/agent-gui/src/lib/subagents/agentTool.ts @@ -130,6 +130,72 @@ const AGENT_PARAMETERS = Type.Object( { additionalProperties: false }, ); +const READONLY_AGENT_PARAMETERS = Type.Object( + { + agents: Type.Array( + Type.Object( + { + id: Type.String({ + minLength: 1, + maxLength: 64, + description: + "Stable agent id (letters, digits, dots, dashes, underscores). Reuse the same id to resume that agent.", + }), + prompt: Type.String({ + minLength: 1, + description: + "Task for this run. For an existing id this is normally the only field needed besides id.", + }), + name: Type.Optional( + Type.String({ description: "Display name. Only valid when the id is first created." }), + ), + role: Type.Optional( + Type.String({ description: "Short role. Only valid when the id is first created." }), + ), + identity: Type.Optional( + Type.String({ + description: + "Long-lived persona/identity instructions. Only valid when the id is first created.", + }), + ), + template: Type.Optional( + Type.String({ + description: + "Enabled AGENTS template id or name. Only valid when the id is first created.", + }), + ), + mode: Type.Optional( + Type.Literal("readonly", { + description: + "This parent conversation is read-only, so delegated agents must use readonly mode.", + }), + ), + resume: Type.Optional( + Type.Boolean({ + description: + "Defaults to true. Set false to start a fresh private context for the same stable id.", + }), + ), + }, + { additionalProperties: false }, + ), + { + minItems: 1, + maxItems: MAX_AGENTS, + description: "One entry per delegated read-only job. Independent entries run in parallel.", + }, + ), + concurrency: Type.Optional( + Type.Integer({ + minimum: 1, + maximum: MAX_AGENTS, + description: `Maximum agents running concurrently. Defaults to ${MAX_AGENTS}.`, + }), + ), + }, + { additionalProperties: false }, +); + async function resolveOutputPaths(params: { agents: ResolvedSubagentSpec[]; workdir: string; @@ -189,6 +255,7 @@ export type SubagentRuntimeConfig = { templates: SubagentTemplate[]; store: SubagentConversationStore; scheduler: SubagentScheduler; + readonlyOnly?: boolean; }; export function createSubagentTools(params: { @@ -207,15 +274,20 @@ export function createSubagentTools(params: { metadataByName: Map; createSubagentToolRegistry?: (workdir: string) => Promise; worktreeIpc?: SubagentWorktreeIpc; + readonlyOnly?: boolean; }): BuiltinToolBundle { const store = params.store; const templates = params.templates; const messageBusEnabled = Boolean(store.conversationId); const worktreeIpc = params.worktreeIpc ?? tauriSubagentWorktreeIpc; - const readonlyTools = selectReadOnlyTools({ - tools: params.baseTools, - metadataByName: params.metadataByName, - }); + const readonlyTools = params.readonlyOnly + ? params.baseTools.filter( + (tool) => params.metadataByName.get(tool.name)?.isReadOnly === true, + ) + : selectReadOnlyTools({ + tools: params.baseTools, + metadataByName: params.metadataByName, + }); const enqueueWorktreeApply = createSequentialQueue(); const agentRunQueues = new Map>(); const enqueueAgentRun = (agentId: string, run: () => Promise) => { @@ -239,9 +311,15 @@ export function createSubagentTools(params: { "Pass one entry per job in `agents`; independent entries run in parallel up to `concurrency`. Use sequential Agent calls only when a later job needs an earlier job's output.", "Each agent has a stable `id` inside this conversation. Reuse the same id to resume that agent's private context; use a new id only for a genuinely new persona.", "Creation fields (name, role, identity, template) apply only when an id is first created; sending different values for an existing id is an error. For an existing id, send only id and the new prompt.", - "mode=readonly (default for new agents) gives inspect-only tools — use it for research, review, and discussion. mode=worktree gives file+shell tools inside an isolated git worktree — use it only when file changes are expected or explicitly requested. A resumed agent keeps its previous mode unless you set mode.", - "apply_policy controls merge-back from a worktree: none (default) never applies, auto applies the patch automatically, explicit applies only when every changed file matches allowed_output_paths.", - "retain_worktree=true keeps a safely-cleanable worktree for review. Worktrees with unapplied changes or failed agents are always retained.", + params.readonlyOnly + ? "This parent conversation is read-only. Every delegated agent must use mode=readonly; worktree mode and merge-back fields are unavailable. Explicitly set mode=readonly when resuming an agent that previously used worktree mode." + : "mode=readonly (default for new agents) gives inspect-only tools — use it for research, review, and discussion. mode=worktree gives file+shell tools inside an isolated git worktree — use it only when file changes are expected or explicitly requested. A resumed agent keeps its previous mode unless you set mode.", + params.readonlyOnly + ? "Delegated agents can inspect and report, but cannot modify workspace files, settings, memory, tasks, or external systems." + : "apply_policy controls merge-back from a worktree: none (default) never applies, auto applies the patch automatically, explicit applies only when every changed file matches allowed_output_paths.", + params.readonlyOnly + ? null + : "retain_worktree=true keeps a safely-cleanable worktree for review. Worktrees with unapplied changes or failed agents are always retained.", "Subagents cannot call Agent recursively. Worktree mode must not modify global LiveAgent settings, MCP server configuration, cron tasks, or user-level skills.", "Subagents communicate through SendMessage (to=parent is parent-private; to=* is a shared broadcast); do not use workspace files as a message channel.", "Include the new user request and any parent-conversation context each subagent needs in that agent's prompt. The parent conversation is not copied automatically.", @@ -250,8 +328,10 @@ export function createSubagentTools(params: { formatRoster(rosterEntries), "Enabled AGENTS templates (reference by template=):", formatTemplates(templateEntries), - ].join("\n"), - parameters: AGENT_PARAMETERS, + ] + .filter((line): line is string => typeof line === "string") + .join("\n"), + parameters: params.readonlyOnly ? READONLY_AGENT_PARAMETERS : AGENT_PARAMETERS, }; async function executeAgentToolCall( @@ -292,6 +372,20 @@ export function createSubagentTools(params: { if (!parsed.ok) { return rejectBatch(parsed.issues); } + if (params.readonlyOnly) { + const permissionIssues = parsed.batch.agents + .filter((agent) => agent.spec.mode !== "readonly") + .map((agent) => + issue( + "permission_denied", + "The parent conversation is read-only; delegated agents must explicitly use mode=readonly.", + agent.spec.id, + ), + ); + if (permissionIssues.length > 0) { + return rejectBatch(permissionIssues); + } + } const { agents, issues: pathIssues } = await resolveOutputPaths({ agents: parsed.batch.agents, workdir: params.workdir, diff --git a/crates/agent-gui/src/lib/subagents/errors.ts b/crates/agent-gui/src/lib/subagents/errors.ts index 974b218ff..2374ea60d 100644 --- a/crates/agent-gui/src/lib/subagents/errors.ts +++ b/crates/agent-gui/src/lib/subagents/errors.ts @@ -13,6 +13,7 @@ export type SubagentIssueCode = | "unknown_agent_id" | "unknown_template" | "identity_conflict" + | "permission_denied" | "worktree_unavailable" | "output_path_outside_workspace" | "unknown_recipient" diff --git a/crates/agent-gui/src/lib/tools/builtinRegistry.ts b/crates/agent-gui/src/lib/tools/builtinRegistry.ts index 4a58f277e..6570bc42c 100644 --- a/crates/agent-gui/src/lib/tools/builtinRegistry.ts +++ b/crates/agent-gui/src/lib/tools/builtinRegistry.ts @@ -297,6 +297,7 @@ export async function buildBuiltinToolRegistry( templates: subagentRuntime.templates, store: subagentRuntime.store, scheduler: subagentRuntime.scheduler, + readonlyOnly: subagentRuntime.readonlyOnly, baseTools: baseRegistry.tools, executeToolCall: baseRegistry.executeToolCall, metadataByName: baseRegistry.metadataByName, diff --git a/crates/agent-gui/src/lib/tools/toolAccessPolicy.ts b/crates/agent-gui/src/lib/tools/toolAccessPolicy.ts new file mode 100644 index 000000000..953ded71d --- /dev/null +++ b/crates/agent-gui/src/lib/tools/toolAccessPolicy.ts @@ -0,0 +1,77 @@ +import type { ToolCall, ToolResultMessage } from "@earendil-works/pi-ai"; +import type { ExecutionMode } from "../settings"; +import { AGENT_TOOL_NAME, SEND_MESSAGE_TOOL_NAME } from "../subagents/types"; +import type { BuiltinToolRegistry } from "./builtinRegistry"; +import type { BuiltinToolMetadata } from "./builtinTypes"; + +export type ToolAccessMode = "none" | "readonly" | "full"; + +export function toolAccessModeForExecutionMode(mode: ExecutionMode): ToolAccessMode { + if (mode === "text") return "none"; + if (mode === "readonly") return "readonly"; + return "full"; +} + +export function isToolAllowedInMode( + mode: ToolAccessMode, + toolName: string, + metadata?: BuiltinToolMetadata, +) { + if (mode === "full") return true; + if (mode === "none") return false; + if (toolName === AGENT_TOOL_NAME || toolName === SEND_MESSAGE_TOOL_NAME) return true; + return metadata?.isReadOnly === true; +} + +function deniedToolResult(toolCall: ToolCall, mode: ToolAccessMode): ToolResultMessage { + return { + role: "toolResult", + toolCallId: toolCall.id, + toolName: toolCall.name, + content: [ + { + type: "text", + text: `Tool access denied: ${toolCall.name} is unavailable in ${mode} mode.`, + }, + ], + details: { kind: "tool_access_denied", accessMode: mode }, + isError: true, + timestamp: Date.now(), + }; +} + +/** Filter model-visible schemas and guard dispatch with the same allowlist. */ +export function restrictBuiltinToolRegistry( + registry: BuiltinToolRegistry, + mode: ToolAccessMode, +): BuiltinToolRegistry { + if (mode === "full") return registry; + + const tools = registry.tools.filter((tool) => + isToolAllowedInMode(mode, tool.name, registry.metadataByName.get(tool.name)), + ); + const canonicalByLookupKey = new Map(tools.map((tool) => [tool.name.toLowerCase(), tool.name])); + const metadataByName = new Map(); + for (const tool of tools) { + const metadata = registry.metadataByName.get(tool.name); + if (metadata) metadataByName.set(tool.name, metadata); + } + + const resolveAllowedName = (toolName: string) => + canonicalByLookupKey.get(toolName.trim().toLowerCase()) ?? null; + + return { + tools, + metadataByName, + hasTool: (toolName) => resolveAllowedName(toolName) !== null, + executeToolCall: (toolCall, signal, context) => { + const canonicalName = resolveAllowedName(toolCall.name); + if (!canonicalName) return Promise.resolve(deniedToolResult(toolCall, mode)); + return registry.executeToolCall( + canonicalName === toolCall.name ? toolCall : { ...toolCall, name: canonicalName }, + signal, + context, + ); + }, + }; +} diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index ae18dcfc8..5c04ad85a 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -123,6 +123,7 @@ import { getSshProjectHostIds, isAgentDevMode, isAgentExecutionMode, + isReadOnlyExecutionMode, isRightDockSingletonTabOpen, normalizeChatRuntimeControls, normalizeChatRuntimeControlsForProvider, @@ -147,6 +148,7 @@ import { updateRightDockWidth, updateSkills, updateSshProjectHostIds, + updateSystem, type WorkspaceProject, workspaceProjectPathKey, } from "../lib/settings"; @@ -3669,6 +3671,7 @@ export function ChatPage(props: ChatPageProps) { gatewayBridgeRequest?.executionModeOverride ?? settings.system.executionMode; const effectiveIsAgentMode = isAgentExecutionMode(effectiveExecutionMode); + const effectiveIsReadOnlyMode = isReadOnlyExecutionMode(effectiveExecutionMode); const effectiveWorkdir = ( overrides?.workdirOverride ?? gatewayBridgeRequest?.workdirOverride ?? @@ -3829,7 +3832,7 @@ export function ChatPage(props: ChatPageProps) { let text = hasTextOverride ? textOverride.trim() : composerDraft - ? (effectiveIsAgentMode && composerDraft.largePastes.length > 0 + ? (effectiveIsAgentMode && !effectiveIsReadOnlyMode && composerDraft.largePastes.length > 0 ? composerDraft.textWithoutLargePastes : buildTextFromComposerDraft(composerDraft) ).trim() @@ -3838,6 +3841,7 @@ export function ChatPage(props: ChatPageProps) { if ( effectiveIsAgentMode && + !effectiveIsReadOnlyMode && composerDraft && composerDraft.largePastes.length > 0 && !hasTextOverride @@ -4198,7 +4202,7 @@ export function ChatPage(props: ChatPageProps) { allowedSkillBaseDirs: [], allowSkillInventory: false, allowSkillManagement: false, - allowSkillMutation: true, + allowSkillMutation: !effectiveIsReadOnlyMode, } : undefined; @@ -4337,9 +4341,11 @@ export function ChatPage(props: ChatPageProps) { } const selectedSkills = selectedSkillNames.map((n) => byName.get(n)!).filter(Boolean); - const allowBuiltinSkillManagement = selectedSkills.some( - (skill) => skill.name === "skills-creator" || skill.name === "skills-installer", - ); + const allowBuiltinSkillManagement = + !effectiveIsReadOnlyMode && + selectedSkills.some( + (skill) => skill.name === "skills-creator" || skill.name === "skills-installer", + ); // IMPORTANT: Claude Code-style skills are progressive disclosure. // We only provide metadata in the system prompt. The model decides whether to read the skill file. @@ -4355,7 +4361,7 @@ export function ChatPage(props: ChatPageProps) { .map((skill) => skill.baseDir), allowSkillInventory: true, allowSkillManagement: allowBuiltinSkillManagement, - allowSkillMutation: true, + allowSkillMutation: !effectiveIsReadOnlyMode, }; const explicitSkills = resolveExplicitSkillMentions({ text, @@ -4377,7 +4383,7 @@ export function ChatPage(props: ChatPageProps) { } const hookScope = createHookRunScope({ - hooks: getAutomationState().hooks.hooks, + hooks: effectiveIsReadOnlyMode ? [] : getAutomationState().hooks.hooks, conversationId, workdir: effectiveWorkdir, onWarning: (warning) => { @@ -4512,6 +4518,7 @@ export function ChatPage(props: ChatPageProps) { }, runtimeModel, selectedModel, + executionMode: effectiveExecutionMode, memoryExtractionModel, onMemoryExtractionModelFailure: handleMemoryExtractionModelFailure, memoryExtractionStatusText, @@ -5206,6 +5213,12 @@ export function ChatPage(props: ChatPageProps) { }, [chatRuntimeReasoningParams, setSettings], ); + const handleExecutionModeChange = useCallback( + (executionMode: ExecutionMode) => { + setSettings((prev) => updateSystem(prev, { executionMode })); + }, + [setSettings], + ); const currentConversationWorkspaceRoot = (() => { const currentItem = historyItems.find((item) => item.id === currentConversationId); const persistedCwd = currentItem?.cwd?.trim(); @@ -5563,6 +5576,8 @@ export function ChatPage(props: ChatPageProps) { workdir={displayedConversationWorkdir} enabledSkills={enabledComposerSkills} isAgentMode={isAgentMode} + executionMode={settings.system.executionMode} + onExecutionModeChange={handleExecutionModeChange} chatRuntimeControls={chatRuntimeControlsForCurrentProvider} reasoningOptions={chatRuntimeReasoningOptions} thinkingAlwaysOn={chatRuntimeThinkingAlwaysOn} diff --git a/crates/agent-gui/src/pages/chat/components/ChatComposerBar.tsx b/crates/agent-gui/src/pages/chat/components/ChatComposerBar.tsx index d783d9873..c83fcbbe4 100644 --- a/crates/agent-gui/src/pages/chat/components/ChatComposerBar.tsx +++ b/crates/agent-gui/src/pages/chat/components/ChatComposerBar.tsx @@ -24,13 +24,16 @@ import { Lightbulb, LightbulbOff, Loader2, + MessageSquare, Paperclip, Play, Send, + Shield, Sparkle, Square, SquarePen, Trash2, + Wrench, X, } from "../../../components/icons"; import { Button } from "../../../components/ui/button"; @@ -50,6 +53,7 @@ import type { GitClient } from "../../../lib/git/types"; import { type ChatRuntimeControls, DEFAULT_CHAT_RUNTIME_CONTROLS, + type ExecutionMode, type ReasoningLevel, } from "../../../lib/settings"; import { cn } from "../../../lib/shared/utils"; @@ -122,6 +126,8 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { workdir: string; enabledSkills: MentionComposerSkill[]; isAgentMode: boolean; + executionMode: ExecutionMode; + onExecutionModeChange: (mode: ExecutionMode) => void; chatRuntimeControls: ChatRuntimeControls; reasoningOptions: ReasoningLevel[]; thinkingAlwaysOn: boolean; @@ -155,6 +161,8 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { workdir, enabledSkills, isAgentMode, + executionMode, + onExecutionModeChange, chatRuntimeControls, reasoningOptions, thinkingAlwaysOn, @@ -194,7 +202,9 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { const [queueScrollbar, setQueueScrollbar] = useState( DEFAULT_QUEUE_SCROLLBAR_STATE, ); - const uploadDisabled = isInputDisabled || isUploadingFiles || !isAgentMode || !workdir; + const visibleExecutionMode = executionMode === "agent-dev" ? "tools" : executionMode; + const uploadDisabled = + isInputDisabled || isUploadingFiles || visibleExecutionMode !== "tools" || !workdir; const controlsDisabled = isInputDisabled; const hasSendableDraft = !composerIsEmpty || pendingUploadedFiles.length > 0; const thinkingSupported = reasoningOptions.length > 0; @@ -220,6 +230,18 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { : t("chat.runtime.thinkingTooltip"); const webSearchTooltip = t("chat.runtime.webSearchTooltip"); const toggleQueueTooltip = queueCollapsed ? t("chat.queue.expand") : t("chat.queue.collapse"); + const executionModes: Array<{ + mode: "text" | "readonly" | "tools"; + label: string; + icon: typeof MessageSquare; + }> = [ + { mode: "text", label: t("settings.chatMode"), icon: MessageSquare }, + { mode: "readonly", label: t("settings.readonlyAgentMode"), icon: Shield }, + { mode: "tools", label: t("settings.agentMode"), icon: Wrench }, + ]; + const selectedExecutionMode = + executionModes.find((option) => option.mode === visibleExecutionMode) ?? executionModes[2]; + const SelectedExecutionModeIcon = selectedExecutionMode.icon; const toggleQueueCollapsed = useCallback(() => { setQueueCollapsed((current) => !current); @@ -623,6 +645,50 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: {
+ + @@ -187,38 +190,76 @@ export function SystemSettingsForm(props: SettingsSectionProps) {
+ + {isClassicAgentMode ? ( + + ) : null}
diff --git a/crates/agent-gui/test/settings/normalization.test.mjs b/crates/agent-gui/test/settings/normalization.test.mjs index 4f7650e03..b7fd643c4 100644 --- a/crates/agent-gui/test/settings/normalization.test.mjs +++ b/crates/agent-gui/test/settings/normalization.test.mjs @@ -2238,3 +2238,15 @@ test("gateway sync merge keeps system proxy password against redacted payloads", assert.equal(mergedHost.system.systemProxy.port, 1080); assert.equal(mergedHost.system.systemProxy.password, "secret"); }); + +test("readonly execution mode survives settings normalization", () => { + const defaults = settings.getDefaultSettings(); + const normalized = settings.normalizeSettings({ + system: { ...defaults.system, executionMode: "readonly" }, + }); + + assert.equal(normalized.system.executionMode, "readonly"); + assert.equal(settings.isAgentExecutionMode("readonly"), true); + assert.equal(settings.isReadOnlyExecutionMode("readonly"), true); + assert.equal(settings.isReadOnlyExecutionMode("tools"), false); +}); diff --git a/crates/agent-gui/test/subagents/agent-tool.test.mjs b/crates/agent-gui/test/subagents/agent-tool.test.mjs index 49aa9416c..66540c832 100644 --- a/crates/agent-gui/test/subagents/agent-tool.test.mjs +++ b/crates/agent-gui/test/subagents/agent-tool.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createAgentToolCall, + createFakeStoreIpc, createRecordingContext, createSubagentHarness, sleep, @@ -91,6 +92,58 @@ test("Agent tool never appears in child tool selections", async () => { } }); +test("readonly parent rejects worktree delegation before an agent starts", async () => { + const harness = await createSubagentHarness({ readonlyOnly: true }); + const result = await harness.bundle.executeToolCall( + createAgentToolCall({ + agents: [{ id: "writer", prompt: "change a file", mode: "worktree" }], + }), + ); + + assert.equal(result.isError, true); + assert.equal(result.details.status, "rejected"); + assert.equal(result.details.issues[0].code, "permission_denied"); + assert.equal(harness.runnerCalls.length, 0); + assert.equal(harness.worktreeIpc.creates.length, 0); +}); + +test("readonly parent requires an explicit downgrade for a stored worktree agent", async () => { + const storeIpc = createFakeStoreIpc(); + storeIpc.seedIdentity({ + parentConversationId: "conversation-1", + agentId: "builder", + name: "Builder", + role: "Implementation", + identityPrompt: "", + lastMode: "worktree", + createdAt: 1, + updatedAt: 2, + }); + const harness = await createSubagentHarness({ readonlyOnly: true, storeIpc }); + const result = await harness.bundle.executeToolCall( + createAgentToolCall({ agents: [{ id: "builder", prompt: "inspect the current state" }] }), + ); + + assert.equal(result.isError, true); + assert.equal(result.details.issues[0].code, "permission_denied"); + assert.equal(harness.runnerCalls.length, 0); +}); + +test("readonly Agent schema omits worktree and merge-back fields", async () => { + const harness = await createSubagentHarness({ readonlyOnly: true }); + const agentTool = harness.bundle.tools.find((tool) => tool.name === "Agent"); + const agentProperties = agentTool.parameters.properties.agents.items.properties; + + assert.deepEqual(agentProperties.mode, { + const: "readonly", + description: "This parent conversation is read-only, so delegated agents must use readonly mode.", + optional: true, + }); + assert.equal("apply_policy" in agentProperties, false); + assert.equal("allowed_output_paths" in agentProperties, false); + assert.equal("retain_worktree" in agentProperties, false); +}); + test("SendMessage is not attached and persistence is skipped without a conversation id", async () => { const harness = await createSubagentHarness({ conversationId: "" }); const result = await harness.bundle.executeToolCall( diff --git a/crates/agent-gui/test/subagents/harness.mjs b/crates/agent-gui/test/subagents/harness.mjs index 5f74f944b..1042e1215 100644 --- a/crates/agent-gui/test/subagents/harness.mjs +++ b/crates/agent-gui/test/subagents/harness.mjs @@ -464,6 +464,7 @@ export async function createSubagentHarness(options = {}) { return createToolResult(toolCall.id, toolCall.name, `base:${toolCall.name}`); }, metadataByName, + readonlyOnly: options.readonlyOnly, createSubagentToolRegistry: options.omitChildRegistry ? undefined : async (workdir) => { diff --git a/crates/agent-gui/test/tools/tool-access-policy.test.mjs b/crates/agent-gui/test/tools/tool-access-policy.test.mjs new file mode 100644 index 000000000..ead93dd2b --- /dev/null +++ b/crates/agent-gui/test/tools/tool-access-policy.test.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +function createTool(name) { + return { name, description: name, parameters: { type: "object", properties: {} } }; +} + +function createMetadata(isReadOnly) { + return { groupId: "fs", kind: "test", isReadOnly, displayCategory: "file" }; +} + +function createToolCall(name, id = `call-${name}`) { + return { type: "toolCall", id, name, arguments: {} }; +} + +function createRegistry() { + const calls = []; + const tools = [ + createTool("Read"), + createTool("Write"), + createTool("Agent"), + createTool("SendMessage"), + createTool("mcp_docs_search"), + ]; + const metadataByName = new Map([ + ["Read", createMetadata(true)], + ["Write", createMetadata(false)], + ["Agent", createMetadata(false)], + ["SendMessage", createMetadata(true)], + ["mcp_docs_search", createMetadata(false)], + ]); + return { + calls, + registry: { + tools, + metadataByName, + hasTool: (name) => tools.some((tool) => tool.name === name), + async executeToolCall(toolCall) { + calls.push(toolCall); + return { + role: "toolResult", + toolCallId: toolCall.id, + toolName: toolCall.name, + content: [{ type: "text", text: `executed:${toolCall.name}` }], + details: {}, + isError: false, + timestamp: Date.now(), + }; + }, + }, + }; +} + +const loader = createTsModuleLoader(); +const { restrictBuiltinToolRegistry, toolAccessModeForExecutionMode } = loader.loadModule( + "src/lib/tools/toolAccessPolicy.ts", +); + +test("execution modes map to none, readonly, and full tool access", () => { + assert.equal(toolAccessModeForExecutionMode("text"), "none"); + assert.equal(toolAccessModeForExecutionMode("readonly"), "readonly"); + assert.equal(toolAccessModeForExecutionMode("tools"), "full"); + assert.equal(toolAccessModeForExecutionMode("agent-dev"), "full"); +}); + +test("readonly schemas expose only classified read tools and delegation", () => { + const { registry } = createRegistry(); + const restricted = restrictBuiltinToolRegistry(registry, "readonly"); + assert.deepEqual( + restricted.tools.map((tool) => tool.name), + ["Read", "Agent", "SendMessage"], + ); + assert.equal(restricted.hasTool("read"), true); + assert.equal(restricted.hasTool("WRITE"), false); +}); + +test("readonly dispatch rejects write and unclassified MCP tools", async () => { + const { registry, calls } = createRegistry(); + const restricted = restrictBuiltinToolRegistry(registry, "readonly"); + const writeResult = await restricted.executeToolCall(createToolCall("Write")); + const mcpResult = await restricted.executeToolCall(createToolCall("mcp_docs_search")); + assert.equal(writeResult.details.kind, "tool_access_denied"); + assert.equal(mcpResult.details.kind, "tool_access_denied"); + assert.deepEqual(calls, []); +}); + +test("allowed dispatch canonicalizes tool names before execution", async () => { + const { registry, calls } = createRegistry(); + const restricted = restrictBuiltinToolRegistry(registry, "readonly"); + const result = await restricted.executeToolCall(createToolCall(" rEaD ")); + assert.equal(result.isError, false); + assert.equal(calls[0].name, "Read"); +}); diff --git a/docs/architecture/gui.md b/docs/architecture/gui.md index 557f25a00..a9aa28c43 100644 --- a/docs/architecture/gui.md +++ b/docs/architecture/gui.md @@ -33,7 +33,8 @@ | 会话运行态 | 当前 conversation、session、message list、live stream、tool status、running/canceling 状态。 | `ChatPage.tsx`、`pages/chat/useChatPageRuntimeStore.ts`、`lib/chat/conversation/liveTranscriptStore.ts` | | 发送入口 | 将用户文本、附件、选中模型、execution mode、workdir、system tools 等合并为 turn request。 | `ChatPage.tsx` | | text 模式 | 只做模型文本流式,不注入本地工具。 | `pages/chat/runTextConversationTurn.ts`、`lib/providers/llm.ts` | -| tools/agent-dev 模式 | 构造 builtin tools,执行模型 tool loop,写工具 trace,并同步 Gateway chat event。 | `pages/chat/runAgentConversationTurn.ts`、`lib/chat/conversation/run/*` | +| readonly 模式 | 构造 builtin tools 后同时过滤模型可见 schema 和实际 dispatch;Memory 只读,禁用写文件、Cron/Hooks、SSH/Tunnel、Skills mutation 和未分类 MCP。 | `pages/chat/runAgentConversationTurn.ts`、`lib/tools/toolAccessPolicy.ts` | +| tools/agent-dev 模式 | 构造完整 builtin tools,执行模型 tool loop,写工具 trace,并同步 Gateway chat event。 | `pages/chat/runAgentConversationTurn.ts`、`lib/chat/conversation/run/*` | | 历史持久化 | V3 segment 写入 Tauri SQLite,支持 append segment、active segment update、rename/delete/pin/share。 | `lib/chat/conversationState.ts`、`src-tauri/src/commands/chat_history.rs` | | 上下文压缩 | 在 pre-send、mid-stream、post-tool 等阶段生成 summary checkpoint,避免超上下文。 | `pages/chat/conversationContextBuilders.ts`、`lib/chat/conversation/compaction/*` | | 记忆注入 | 每轮根据 workdir 读取 memory overview,并附加到 system prompt。 | `lib/chat/memory/memoryPrompt.ts`、`src-tauri/src/services/memory.rs` | diff --git a/docs/features/chat-runtime.md b/docs/features/chat-runtime.md index 055b4f2e3..c737f9506 100644 --- a/docs/features/chat-runtime.md +++ b/docs/features/chat-runtime.md @@ -5,8 +5,9 @@ | 模式 | 入口 | 工具 | 典型用途 | |---|---|---|---| | `text` | `runTextConversationTurn.ts` | 不启用本地工具 | 纯模型聊天、低权限文本回答。 | +| `readonly` | `runAgentConversationTurn.ts` | 仅启用显式标记为只读的 builtin tool,以及受限的 `Agent`/`SendMessage` | 项目检查、检索和只读子代理委派,不修改本地或外部状态。 | | `tools` | `runAgentConversationTurn.ts` | 启用 builtin tool registry | 常规 Agent 模式,支持文件、Shell、MCP、Skills、Memory、Cron 等。 | -| `agent-dev` | `runAgentConversationTurn.ts` | 启用工具,并展示更多 debug/usage/silent memory 细节 | 开发调试与可观测性更强的 Agent 模式。 | +| `agent-dev` | `runAgentConversationTurn.ts` | 与 `tools` 相同,并写入 debug JSONL trace | Settings 中开启 Agent 调试记录后的兼容持久化值。 | ## 主流程 @@ -15,7 +16,7 @@ | 1 | 收集用户输入、附件、选中模型、execution mode、workdir、system tools。 | `ChatPage.tsx`、`ChatComposerBar.tsx` | | 2 | 加载 Skills prompt、Memory overview、hooks、历史上下文和当前 active segment。 | `useChatSkills.ts`、`memoryPrompt.ts`、`conversationState.ts` | | 3 | 构造模型 request context,必要时先触发 pre-send compaction。 | `conversationContextBuilders.ts`、`compaction/*` | -| 4 | text 模式直接 stream assistant;tools/agent-dev 构造工具 registry 并进入 tool loop。 | `llm.ts`、`builtinRegistry.ts`、`runAgentConversationTurn.ts` | +| 4 | text 模式直接 stream assistant;readonly/tools/agent-dev 构造工具 registry,按 execution mode 限制 schema 与 dispatch 后进入 tool loop。 | `llm.ts`、`builtinRegistry.ts`、`toolAccessPolicy.ts`、`runAgentConversationTurn.ts` | | 5 | 流式 token/thinking/hosted search/tool status 更新 transcript,并发布 Gateway event。 | `liveTranscriptStore.ts`、`gatewayBridgeEvents.ts` | | 6 | 工具调用通过 registry 分派到对应 executor,结果回填模型上下文。 | `lib/tools/*`、`lib/chat/conversation/run/*` | | 7 | turn 结束后写入 chat history,生成标题,触发 silent memory extraction 和 hooks。 | `chat_history.rs`、`conversationTitleJob.ts`、`silentMemoryExtraction.ts` | @@ -37,7 +38,7 @@ |---|---| | system prompt | 默认系统提示、用户 system settings、Skills prompt、Memory overview、压缩 summary。 | | messages | 当前 active segment 中的 user/assistant/toolResult 历史,经过 sanitizer。 | -| tools | text 模式为空,agent 模式来自 builtin registry 和动态 MCP tools。 | +| tools | text 模式为空;readonly 仅保留明确分类的只读工具;完整 Agent 模式来自 builtin registry 和动态 MCP tools。 | | attachments | uploaded files 转成模型可见文本/图片引用,图片 bytes 按上下文策略清洗。 | | hosted search | provider 或 probe 捕获的 search block 进入消息内容和 UI。 | @@ -66,7 +67,7 @@ Hooks 支持 shell script 和 HTTP requests,设置由 GUI/WebUI 同步维护 | 能力 | 语义 | |---|---| -| 文件上传 | 仅在 tools 类模式可用,文件导入 workspace uploads,模型看到受控引用。 | +| 文件上传 | 仅在完整 Agent 模式可用,文件导入 workspace uploads;readonly 不创建 workspace 文件。 | | 图片预览 | GUI/WebUI 都支持用户附件、Image 工具图片和 inline tool result 图片预览。 | | 编辑重发 | 从目标 user message 处 truncate 后重发,保持历史语义与 GUI/WebUI 一致。 | | 附件-only 重发 | 支持仅靠已有附件重新发起请求。 | diff --git a/docs/features/tools.md b/docs/features/tools.md index 65ab9258f..301337665 100644 --- a/docs/features/tools.md +++ b/docs/features/tools.md @@ -34,6 +34,16 @@ | WebUI Chat | 间接执行 | WebUI 发 Chat Command 到 Gateway,实际工具仍在桌面 GUI/Tauri 运行。 | | Gateway | 否 | Gateway 不执行业务工具,只转发 request/event 并维护 buffer。 | +主会话还会通过 `toolAccessPolicy.ts` 将 execution mode 映射到统一授权级别,并对模型可见 schema 与实际 executor dispatch 使用同一 allowlist: + +| Execution mode | 工具授权 | 约束 | +|---|---|---| +| `text` | `none` | 不构造本地工具调用流程。 | +| `readonly` | `readonly` | 仅允许 `BuiltinToolMetadata.isReadOnly=true` 的工具,以及受限的 `Agent`/`SendMessage`;未分类工具默认拒绝。 | +| `tools` / `agent-dev` | `full` | 使用完整 registry;`agent-dev` 额外写调试 trace。 | + +readonly runtime 将 MemoryManager 降为只读 schema,不注册 TodoWrite,不运行会话 hooks 或 silent memory extraction,并禁用 Skills mutation、Cron、SSH、Tunnel、MCP 配置写入和动态 MCP 工具。上传与大段粘贴导入也不会创建 workspace 文件。即使调用方绕过 schema 直接提交工具名,受限 registry 的 dispatch 仍返回 `tool_access_denied`,不会进入原 executor。 + ## MCP 动态工具 | 阶段 | 说明 | @@ -81,6 +91,7 @@ | 结构化参数 | `agents` 数组(每项 `id/prompt/name/role/identity/template/mode/apply_policy/allowed_output_paths/resume/retain_worktree`)+ 顶层 `concurrency`,单次最多 8 个 agent 并行。 | | 稳定 id 与复用 | 同一会话内复用 id 即恢复该子代理的私有上下文;`name/role/identity/template` 只在 id 首次创建时生效,对既有 id 传入不同值会被拒绝。`resume=false` 为同一 id 开启全新私有上下文。 | | mode | `readonly`(新 agent 默认,只读工具)用于调研/评审;`worktree` 在隔离 git worktree 内提供文件+shell 工具。resume 的 agent 默认沿用上次 mode。 | +| readonly 父会话 | `Agent` schema 隐藏 worktree、apply 与 retain 字段;所有子代理必须为 `mode=readonly`。既有 worktree agent 未显式降级时整批拒绝,且拒绝发生在创建 worktree 之前。 | | apply_policy | `none`(默认,不回灌)/`auto`(自动 apply patch)/`explicit`(仅当所有变更文件命中 `allowed_output_paths` 才 apply;路径必须解析进 workspace)。`retain_worktree=true` 保留可安全清理的 worktree 供复查。 | | 原子校验 | 校验失败时不启动任何 agent,返回结构化错误并附上当前 roster 与已启用模板列表;`AgentPromptTemplate.enabled` 生效,`template` 只能引用已启用模板(按 id 或 name 解析)。 | | SendMessage | `to=parent`(父私有)/`to=*`(共享广播)/`to=`(直达),收件人按 roster 校验,未知收件人直接拒绝;channel 为 direct/shared/decision/question,消息在下一轮 turn 边界投递。 | From 320330c4d4c7ad95b61e4282fef07778e01a7f45 Mon Sep 17 00:00:00 2001 From: zaixiaziyang <62634142+zaixiaziyang@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:34:21 +0800 Subject: [PATCH 2/2] style(agent): satisfy frontend formatter --- crates/agent-gateway/web/src/i18n/config.ts | 3 +-- crates/agent-gui/src/i18n/config.ts | 3 +-- crates/agent-gui/src/lib/subagents/agentTool.ts | 4 +--- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index 301d4cd96..f07bccd82 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -1029,8 +1029,7 @@ export const translations: Record> = { "settings.fontSizeLarge": "大", "settings.fontSizeXLarge": "特大", "settings.executionMode": "执行模式", - "settings.executionModeDesc": - "选择新对话的默认权限;当前对话也可从输入框切换。", + "settings.executionModeDesc": "选择新对话的默认权限;当前对话也可从输入框切换。", "settings.chatMode": "Chat 模式", "settings.chatModeDesc": "纯文本对话,模型只输出文本与 Markdown", "settings.readonlyAgentMode": "只读 Agent", diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index bc355930c..ff1b153b2 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -1102,8 +1102,7 @@ export const translations: Record> = { "settings.fontSizeLarge": "大", "settings.fontSizeXLarge": "特大", "settings.executionMode": "执行模式", - "settings.executionModeDesc": - "选择新对话的默认权限;当前对话也可从输入框切换。", + "settings.executionModeDesc": "选择新对话的默认权限;当前对话也可从输入框切换。", "settings.chatMode": "Chat 模式", "settings.chatModeDesc": "纯文本对话,模型只输出文本与 Markdown", "settings.readonlyAgentMode": "只读 Agent", diff --git a/crates/agent-gui/src/lib/subagents/agentTool.ts b/crates/agent-gui/src/lib/subagents/agentTool.ts index 0cfa5573f..8072cf973 100644 --- a/crates/agent-gui/src/lib/subagents/agentTool.ts +++ b/crates/agent-gui/src/lib/subagents/agentTool.ts @@ -281,9 +281,7 @@ export function createSubagentTools(params: { const messageBusEnabled = Boolean(store.conversationId); const worktreeIpc = params.worktreeIpc ?? tauriSubagentWorktreeIpc; const readonlyTools = params.readonlyOnly - ? params.baseTools.filter( - (tool) => params.metadataByName.get(tool.name)?.isReadOnly === true, - ) + ? params.baseTools.filter((tool) => params.metadataByName.get(tool.name)?.isReadOnly === true) : selectReadOnlyTools({ tools: params.baseTools, metadataByName: params.metadataByName,