diff --git a/coworker/agent.py b/coworker/agent.py index c958a85498..4dcbd4302c 100644 --- a/coworker/agent.py +++ b/coworker/agent.py @@ -239,6 +239,9 @@ def build_engine( connector_filter: Optional[set[str]] = None, # A set (static snapshot) or a zero-arg callable (live, re-evaluated per load_skill). skill_filter: Optional[set[str] | Callable[[], set[str]]] = None, + # Optional approval-prompt intent analyzer (dependency injection): None = off + # (upstream behavior unchanged). + intent_analyzer: Optional[Callable] = None, # Auto-Approve flags (spec Part 8 / §1.5). None ⇒ read the config.toml value; the server # passes its prefs-backed booleans so the GUI Settings toggle takes effect. Both stores # are user-global, preserving the "a repo can't enable this" invariant. @@ -542,6 +545,7 @@ def context_provider() -> str: tool_requester=tool_requester, team_approver=team_approver, items_approver=items_approver, + intent_analyzer=intent_analyzer, ) engine.executor = executor # type: ignore[attr-defined] engine.todo = todo # type: ignore[attr-defined] diff --git a/coworker/engine.py b/coworker/engine.py index f16afe8653..c481fa5be3 100644 --- a/coworker/engine.py +++ b/coworker/engine.py @@ -14,12 +14,15 @@ import asyncio import json +import logging import time from dataclasses import dataclass, replace from enum import Enum from typing import Any, AsyncIterator, Awaitable, Callable, Optional from . import compaction as _compaction + +logger = logging.getLogger(__name__) from . import provenance from . import session_facts from . import toolchain as _toolchain @@ -66,6 +69,7 @@ class PermissionRequest: metadata: Any reason: str tool_call_id: Optional[str] = None # for durable resume (idempotent inbox item) + intent: Optional[str] = None # plain-language consequences, for the approval card Approver = Callable[[PermissionRequest], Awaitable[ApprovalOutcome]] @@ -111,10 +115,18 @@ def __init__( # Called (thread-safe, best-effort) when the user stops the turn — e.g. the # executor's kill for a running shell command. interrupt_hooks: Optional[list[Callable[[], None]]] = None, + + # Optional approval-prompt intent analyzer (off unless injected). None keeps + # upstream behavior exactly; injected analyzers run one extra single-turn + # provider.complete per approval card. + intent_analyzer: Optional[Callable] = None, + intent_analyzer_timeout: float = 20.0, ) -> None: self.provider = provider self.registry = registry self.permissions = permissions + self.intent_analyzer = intent_analyzer # None = feature off + self.intent_analyzer_timeout = intent_analyzer_timeout self.model = model self.approver = approver or _deny_all self.max_iterations = max_iterations @@ -1234,6 +1246,49 @@ async def _authorize(self, tool_call: ToolCall) -> "AsyncIterator[Event | bool]" # to the card is already audited as reviewer_verdict — no double spend). if not consulted_live: self._spawn_shadow_review(tool_call) + + # Intent analysis (this PR): runs ONLY when a card will actually be shown — + # the reviewer above already resolved allowed/denied cases, so the extra + # round trip is never wasted. None ⇒ feature off (upstream behavior + # unchanged). Stop/timeout/exceptions degrade to intent=None and never + # break _authorize. + intent: Optional[str] = None + if self.intent_analyzer: + async def _do_analyze(): + return await asyncio.wait_for( + asyncio.to_thread( + self.intent_analyzer, tool_call, self.provider, self.model + ), + timeout=self.intent_analyzer_timeout, # covers cloud-model tail latency + ) + # wait_for re-raises TimeoutError via task.result(); _interruptible does + # not swallow it, so this try/except is load-bearing. + try: + intent = await self._interruptible(_do_analyze(), interrupted=None) + except asyncio.TimeoutError: + logger.warning( + "intent_analysis: timed out (%.0fs); the card will render without the annotation. tool=%s", + self.intent_analyzer_timeout, + getattr(tool_call, "name", "?"), + ) + intent = None + except Exception: + # The analyzer logs its own failures; this is only a floor guard. + intent = None + + # A user Stop during the analysis must not flash a card that is about to + # die: go straight to the interrupted-denial path. + if self._cancel.is_set(): + self.messages.append(_tool_error_message(tool_call, "interrupted by user")) + self._audit( + tool_call, stage="finished", status="interrupted", reason="user stop" + ) + yield Event( + EventType.TOOL_FINISHED, + {"name": tool_call.name, "status": "interrupted", "reason": "stopped"}, + ) + yield False + return yield Event( EventType.PERMISSION_REQUIRED, { @@ -1263,6 +1318,8 @@ async def _authorize(self, tool_call: ToolCall) -> "AsyncIterator[Event | bool]" # True when this shell command classifies as read-only — the card # offers "Allow read-only commands for this session" only then. "readonly_ok": _readonly_ok(tool_call.arguments), + # The plain-language annotation for the card; null = render without it. + "intent": intent, **( self.approval_extras(tool_call.name, tool_call.arguments) if self.approval_extras diff --git a/coworker/intent_analysis/__init__.py b/coworker/intent_analysis/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coworker/intent_analysis/analyzer.py b/coworker/intent_analysis/analyzer.py new file mode 100644 index 0000000000..76857442c5 --- /dev/null +++ b/coworker/intent_analysis/analyzer.py @@ -0,0 +1,91 @@ +"""AI command intent analysis — explain an operation's consequences before approval. + +Mirrors the compaction summarize_span pattern: one single-turn provider.complete with +tools disabled and no side effects. Failure / timeout / non-bullet output all return +None (the engine then simply omits the annotation); the reason is logged as a warning +so "no annotation" is distinguishable between "feature off" and "analysis failed". + +Positional signature — the engine calls + asyncio.to_thread(self.intent_analyzer, tool_call, self.provider, self.model) +`language` rides in via the wrapper the server builds (defaults to English). +""" +import json +import logging +from typing import Any, Optional, Protocol + +from .prompts import build_system_prompt, build_user_prompt + +logger = logging.getLogger(__name__) + + +class _ToolCallLike(Protocol): + name: str + arguments: dict[str, Any] + + +# Aligned with coworker/risk.py's WRITE_TOOLS +_WRITE_TOOLS = {"write_file", "replace_in_file", "apply_patch", "apply_unified_diff"} +_SEND_TOOLS = {"send_message", "send_file"} + + +def extract_input(tool_call: _ToolCallLike) -> str: + """Build the operation description handed to the LLM, structured per tool kind.""" + name = tool_call.name + args = tool_call.arguments or {} + if name == "run_shell" and args.get("command"): + return str(args["command"]) + if name in _WRITE_TOOLS: + path = args.get("path", "") + return f"Operation: {name}\nPath: {path}" + if name in _SEND_TOOLS: + # send_message's real arg is target (connectors/tools.py), not destination/channel + target = args.get("target") or args.get("destination") or args.get("channel") or "" + content = args.get("text") or args.get("content") or "" + return f"Operation: {name}\nTarget: {target}\nContent: {content}" + return f"Operation: {name}\nArgs: {json.dumps(args, ensure_ascii=False)}" + + +def _clean(text: str) -> Optional[str]: + """Keep only bullet lines (`• ` / `- `); strip fences and blank lines.""" + if not text: + return None + lines = [] + for line in text.splitlines(): + line = line.strip().strip("`") + if line.startswith("• ") or line.startswith("- "): + lines.append(line) + return "\n".join(lines) if lines else None + + +def analyze(tool_call, provider, model, language="en") -> Optional[str]: + """Produce the intent annotation. **Synchronously blocking** (the engine wraps it + in asyncio.to_thread). The engine-side wait_for enforces the timeout; this function + swallows provider errors but logs the reason as a warning. + Returns: bullet-point text; None on failure/empty. + """ + try: + messages = [ + {"role": "system", "content": build_system_prompt(language, 2)}, + {"role": "user", "content": build_user_prompt(extract_input(tool_call))}, + ] + turn = provider.complete( + model=model, messages=messages, tools=None, max_tokens=300 + ) + cleaned = _clean(getattr(turn, "text", None)) + if cleaned is None: + # The model answered but without bullet markers; filtered to None. Logged so prompt + # drift shows up instead of silently degrading to "no annotation". + logger.warning( + "intent_analysis: no bullet lines in LLM output, filtered to None; tool=%s raw=%.200s", + getattr(tool_call, "name", "?"), + getattr(turn, "text", None) or "", + ) + return cleaned + except Exception as exc: + logger.warning( + "intent_analysis: call failed, returning None (card renders without the annotation); tool=%s %s: %s", + getattr(tool_call, "name", "?"), + type(exc).__name__, + exc, + ) + return None diff --git a/coworker/intent_analysis/prompts.py b/coworker/intent_analysis/prompts.py new file mode 100644 index 0000000000..4f9b1f557a --- /dev/null +++ b/coworker/intent_analysis/prompts.py @@ -0,0 +1,103 @@ +"""Prompts for the approval-prompt intent analysis. + +The analyzer asks the session's own model to explain, in plain language and a couple +of bullet points, what an operation will do — so a person who can't read the raw +command can still make an informed approve/deny decision. Output language follows the +user's UI language (`language` selects the template; English by default). +""" + +MAX_INPUT_CHARS = 2000 +MAX_BULLETS = 6 + +_EN_SYSTEM = """You are an expert at explaining operation consequences to users. The user is about to approve an operation. Your job is to clearly explain the consequences using bullet points so they can make an informed decision. + +# Rules +1. Format: Use bullet points (starting with "• "). Each point describes one specific consequence. 1-{n} bullet points total. +2. Language: STRICTLY output in English. +3. Emphasis: Wrap the most critical keywords with **double asterisks** for bold. Only bold the most important terms (1-2 per bullet), such as action verbs and irreversible consequences. +4. Focus: Each bullet should answer one of: What will happen? What will be affected? What is the risk/consequence? +5. Dangerous operations: If the operation involves destructive or risky actions (rm, delete, drop, kill, format, overwrite, force push, reset --hard, chmod 777, truncate, revoke, clear, etc.), you MUST emphasize severity and irreversibility. +6. Non-dangerous operations: Still use bullet points, but in a neutral helpful tone without severity emphasis. +7. Do not call tools. Do not include greetings, analysis, code fences, or extra explanation. Output only the bullet points. + +# Examples +Operation: run_shell +Command: rm ~/Desktop/test.sh +Intent: +• The rm command will **permanently delete** the file, this action is irreversible +• The file **cannot be recovered** from Trash after deletion + +Operation: run_shell +Command: git push --force origin main +Intent: +• Will **forcefully overwrite** the remote main branch history +• Other people's code on this branch may be lost +• This action cannot be easily undone + +Operation: write_file +Path: /etc/config.json +Intent: +• Will **overwrite** the file's existing content +• The original content cannot be recovered afterwards + +Operation: send_message +Target: slack:#ops-channel +Intent: +• Will send a message to #ops-channel, visible to everyone there +• The message cannot be unsent afterwards +""" + +_ZH_SYSTEM = """你是向用户解释操作后果的专家。用户即将批准一个操作执行。你的任务是用项目符号清楚说明后果,帮他们做明智决定。 + +# 规则 +1. 格式:用项目符号("• "开头),每点说一个具体后果,共 1-{n} 条 +2. 语言:严格输出中文 +3. 强调:最危险的关键词用 **双星号加粗**,每条最多加粗 1-2 个动作动词或不可逆后果 +4. 聚焦:每条回答其一——会发生什么?会影响什么?有什么风险/后果? +5. 危险操作:如果操作涉及 rm、delete、drop、kill、format、overwrite、force push、reset --hard、chmod 777、truncate、撤回、清空等破坏性或风险动作,必须强调严重性和不可逆性 +6. 非危险操作:仍用项目符号,但语气中性,不强调严重性 +7. 不调工具。不加问候、分析、代码块或多余解释。只输出项目符号本身。 + +# 示例 +Operation: run_shell +Command: rm ~/Desktop/test.sh +Intent: +• rm 命令将**永久删除**文件,操作不可撤销 +• 文件删除后**无法从废纸篓恢复** + +Operation: run_shell +Command: git push --force origin main +Intent: +• 将**强制覆盖**远程仓库的 main 分支历史记录 +• 其他人在该分支上提交的代码**可能丢失** +• 此操作**不可轻易撤销** + +Operation: write_file +Path: /etc/config.json +Intent: +• 将**覆盖**该文件的现有内容 +• 原内容事后无法恢复 + +Operation: send_message +Target: slack:#ops-channel +Intent: +• 将向 #ops-channel 发送消息,频道内所有人可见 +• 发送后无法撤回 +""" + + +def build_system_prompt(language: str, max_bullets: int) -> str: + """Build the system prompt in the requested language. max_bullets is clamped to + 1..MAX_BULLETS. Unknown/empty language falls back to English.""" + n = max(1, min(MAX_BULLETS, max_bullets)) + template = _ZH_SYSTEM if (language or "").lower().startswith("zh") else _EN_SYSTEM + return template.format(n=n) + + +def build_user_prompt(operation_input: str) -> str: + """Build the user prompt. Long input is truncated to MAX_INPUT_CHARS.""" + operation_input = operation_input or "" + truncated = operation_input[:MAX_INPUT_CHARS] + if len(operation_input) > MAX_INPUT_CHARS: + truncated += "..." + return f"# Input\n{truncated}\n\n# Output\nReturn only the bullet points." diff --git a/coworker/server/app.py b/coworker/server/app.py index 457c08110b..83ea4dec7a 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -1907,6 +1907,14 @@ def settings_set_sessions_peek(body: dict) -> dict[str, Any]: # Sidebar: sessions shown per group before "Show more" (owner ask, 2026-07-03). return manager.set_sessions_peek((body or {}).get("sessions_peek", 5)) + # Approval-prompt intent analysis (this PR): toggle + annotation language. + @app.post("/v1/settings/intent-analysis") + def settings_set_intent_analysis(body: dict) -> dict[str, Any]: + return manager.set_intent_analysis( + bool((body or {}).get("enabled", True)), + str((body or {}).get("language") or "en"), + ) + @app.post("/v1/settings/context-bar") def settings_set_context_bar(body: dict) -> dict[str, Any]: # Composer: show the context-window fill bar, or just the popover (owner ask). diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 130ede1f23..16048e1cee 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -646,6 +646,13 @@ def get_engine( else: # A session id we won't put in a filesystem path: primary root only. roots = [{"path": ws, "writable": True, "label": "workspace"}, *extra] + # Intent analysis (this PR): injected when the pref is on; the wrapper binds the + # annotation language stored beside the pref (follows the UI language, "en" default). + intent_analyzer = None + if self._prefs.get("intent_analysis"): + from ..intent_analysis.analyzer import analyze + _lang = str(self._prefs.get("intent_analysis_lang") or "en") + intent_analyzer = lambda tc, prov, mdl: analyze(tc, prov, mdl, language=_lang) engine = build_engine( agent=ag, workspace=ws, @@ -690,6 +697,7 @@ def get_engine( tool_requester=tool_requester, team_approver=team_approver, items_approver=items_approver, + intent_analyzer=intent_analyzer, subscription_store=self.subscriptions, channel_buffer=self.channel_buffer, routing_targets=self._routing_targets(session_id, agent), @@ -1962,6 +1970,7 @@ def post_chat(text: str, record_on_item: Optional[int] = None) -> dict: metadata=ai.ToolMetadata( category="team", risk_level="low", capabilities=["team"] ), + intent_analyzer=intent_analyzer, ) def _chat_identity(self, session_id: str, role: str): @@ -3325,6 +3334,10 @@ def _selectable(m: str) -> bool: "nav_layout": self._nav_layout(), "sessions_peek": self.sessions_peek(), "context_bar": self.context_bar(), + # Approval-prompt intent analysis (this PR): off by default; when on, the + # annotation language follows the UI language (stored beside the flag). + "intent_analysis": self._prefs.get("intent_analysis", False), + "intent_analysis_lang": self._prefs.get("intent_analysis_lang", "en"), # Auto-Approve feature flag + its shadow-eval sibling (spec §1.5). Drive the # Settings toggles and gate the composer's Auto-Approve mode entry. "auto_approve": self.auto_approve(), @@ -3363,6 +3376,19 @@ def _nav_layout(self) -> str: prefs (UI-REFRESH §7).""" return "grouped" if self._prefs.get("nav_layout") == "grouped" else "flat" + def set_intent_analysis(self, enabled: bool, language: str = "en") -> dict[str, Any]: + """Toggle approval-prompt intent analysis. `language` sets the annotation + language (the GUI passes its current UI language). Applies on the next + session build.""" + self._prefs["intent_analysis"] = bool(enabled) + self._prefs["intent_analysis_lang"] = str(language or "en")[:8] + self._save_prefs() + return { + "ok": True, + "intent_analysis": bool(enabled), + "intent_analysis_lang": self._prefs["intent_analysis_lang"], + } + def set_nav_layout(self, nav_layout: str) -> dict[str, Any]: """Set + persist the sidebar layout. Unknown values fall back to ``"flat"``.""" value = "grouped" if (nav_layout or "").strip() == "grouped" else "flat" @@ -4120,6 +4146,9 @@ def approval_prompt_data(self, session_id: str, request) -> dict[str, Any]: "tool": request.tool_name, "arguments": getattr(request, "arguments", None) or {}, } + intent = getattr(request, "intent", None) + if intent: + data["intent"] = intent task = self.task_store.task_for_run_session(session_id) if task is None: return data @@ -4320,6 +4349,11 @@ def _seed_task_permissions(self, engine: TurnEngine, task) -> None: def _build_task_engine(self, task, *, session_id: str) -> TurnEngine: ag = get_agent(task.agent) Path(task.workspace).mkdir(parents=True, exist_ok=True) + intent_analyzer = None + if self._prefs.get("intent_analysis"): + from ..intent_analysis.analyzer import analyze + _lang = str(self._prefs.get("intent_analysis_lang") or "en") + intent_analyzer = lambda tc, prov, mdl: analyze(tc, prov, mdl, language=_lang) engine = build_engine( agent=ag, workspace=task.workspace, diff --git a/docs/images/intent-analysis-card.png b/docs/images/intent-analysis-card.png new file mode 100644 index 0000000000..d4bc4c908c Binary files /dev/null and b/docs/images/intent-analysis-card.png differ diff --git a/docs/images/intent-analysis-session.png b/docs/images/intent-analysis-session.png new file mode 100644 index 0000000000..35272fb6d7 Binary files /dev/null and b/docs/images/intent-analysis-session.png differ diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index d87322d361..dcc911b94a 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -890,6 +890,10 @@ export interface ModelSettings { // hides the Auto-Approve mode entry unless auto_approve is explicitly true. auto_approve?: boolean; auto_approve_shadow?: boolean; + // Approval-prompt intent analysis (this PR): off by default; the annotation language + // follows the UI language. + intent_analysis?: boolean; + intent_analysis_lang?: string; // Curated-matrix display names ({full id → "GLM-5.2 · via Together"}); custom models absent. model_labels?: Record; // {full id → context window in tokens}, verified matrix entries only — drives the @@ -975,6 +979,20 @@ type AutoApproveResult = { error?: string; }; +/** Toggle approval-prompt intent analysis; `language` is the annotation language + * (pass the current UI language so the annotation matches the interface). */ +export async function setIntentAnalysis( + enabled: boolean, + language: string = "en", +): Promise<{ ok: boolean; intent_analysis?: boolean; intent_analysis_lang?: string; error?: string }> { + const res = await fetch(`${httpBase()}/v1/settings/intent-analysis`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled, language }), + }); + return res.json(); +} + /** Toggle the Auto-Approve feature flag (spec §1.5); applies to the next session build. */ export async function setAutoApprove(on: boolean): Promise { const res = await fetch(`${httpBase()}/v1/settings/auto-approve`, { diff --git a/surfaces/gui/src/components/ApprovalCard.test.tsx b/surfaces/gui/src/components/ApprovalCard.test.tsx index de2fd5dc90..ad6bbb3643 100644 --- a/surfaces/gui/src/components/ApprovalCard.test.tsx +++ b/surfaces/gui/src/components/ApprovalCard.test.tsx @@ -449,3 +449,47 @@ describe("ApprovalCard — file provenance", () => { expect(screen.getByText(/downloaded by the agent/)).toBeTruthy(); }); }); + + +// -- AI intent block (this PR) ------------------------------------------------ +describe("ApprovalCard — AI intent block", () => { + it("renders the intent block when item.intent is present", () => { + render(); + expect(screen.getByText(/Danger/)).toBeTruthy(); + expect(screen.getByText(/Irreversible/)).toBeTruthy(); + }); + + it("renders bolded critical terms as emphasis", () => { + render( + , + ); + expect(document.querySelector(".approval-intent-em")?.textContent).toBe("permanently deleted"); + }); + + it("does not render the intent block when item.intent is absent", () => { + const { container } = render(); + expect(container.querySelector(".approval-intent")).toBeNull(); + }); + + it("forces the full card for routine file writes when intent is present", () => { + // FILE_WRITES render as a compact row (early return); an intent must upgrade to + // the full card so the annotation is actually visible. + render( + , + ); + expect(screen.getByText(/Overwrites the file/)).toBeTruthy(); + expect(screen.queryByTestId("approval-row")).toBeNull(); + }); +}); diff --git a/surfaces/gui/src/components/ApprovalCard.tsx b/surfaces/gui/src/components/ApprovalCard.tsx index f703887d16..7b4cc1df8f 100644 --- a/surfaces/gui/src/components/ApprovalCard.tsx +++ b/surfaces/gui/src/components/ApprovalCard.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { getI18n, useTranslation } from "react-i18next"; import type { ApprovalDecision, Item } from "../types"; import { humanizeApprovalTitle, type HumanLine } from "../humanize"; @@ -32,6 +32,21 @@ const FILE_WRITES = new Set(["write_file", "replace_in_file", "apply_patch", "ap // Actions that leave the Mac get the warm border + explicit destination note. const EXTERNAL = new Set(["send_message", "send_file"]); +// LLM intent text: **bold** → (lightweight regex, not full markdown); strip the +// leading • / · / - / * bullet prefix (the dot is rendered by a CSS span, not a character). +export function renderIntentText(line: string): ReactNode { + const stripped = line.replace(/^[\s•·\-*]+/, "").trim(); + if (!stripped) return null; + const parts = stripped.split(/(\*\*[^*]+\*\*)/g); + return parts.map((p, i) => + p.startsWith("**") && p.endsWith("**") ? ( + {p.slice(2, -2)} + ) : ( + {p} + ) + ); +} + type ApprovalItem = Extract; // Per-tool button copy (§7): a skill proposal is an "add", not an "allow". Shared with the @@ -345,7 +360,7 @@ export function ApprovalCard({ // §35 compact row: routine workspace writes — one line, preview expands inline from the // tool args. Standing/grant flows keep the full card (they carry §25 consent weight). const content = typeof item.args?.content === "string" ? item.args.content : ""; - if (FILE_WRITES.has(item.name) && !offerStanding && !grants.length && !item.resolved) { + if (FILE_WRITES.has(item.name) && !offerStanding && !grants.length && !item.resolved && !item.intent) { return (
@@ -382,6 +397,17 @@ export function ApprovalCard({
{scope.text} + + {item.intent && ( +
    + {item.intent.split("\n").map((line, i) => ( +
  • + + {renderIntentText(line)} +
  • + ))} +
+ )}
{/* Tool-shaped previews — the proposal, not an args dump. */} diff --git a/surfaces/gui/src/components/InboxItemCard.tsx b/surfaces/gui/src/components/InboxItemCard.tsx index f5bc18dfeb..1e429a18f9 100644 --- a/surfaces/gui/src/components/InboxItemCard.tsx +++ b/surfaces/gui/src/components/InboxItemCard.tsx @@ -8,8 +8,7 @@ import { PreviewBlock, SaveSkillPreview, scopeNote, - TitleText, -} from "./ApprovalCard"; + TitleText, renderIntentText } from "./ApprovalCard"; // One Inbox item, rendered identically in the Inbox list and inline in its own session view // (answer-in-context). Resolving either place hits the same item id — first responder wins. @@ -332,6 +331,16 @@ export function InboxItemCard({
{item.title}
)} + {item.kind === "approval" && item.data?.intent && ( +
    + {String(item.data.intent).split("\n").map((line, i) => ( +
  • + + {renderIntentText(line)} +
  • + ))} +
+ )} {item.kind === "approval" && item.data?.tool === "save_skill" ? ( // Parked skill proposals wear the same review surface as the live card (§5.2). diff --git a/surfaces/gui/src/components/SettingsView.tsx b/surfaces/gui/src/components/SettingsView.tsx index 4c65970a52..9806021fb7 100644 --- a/surfaces/gui/src/components/SettingsView.tsx +++ b/surfaces/gui/src/components/SettingsView.tsx @@ -16,8 +16,7 @@ import { type CompactionSettings, type ModelSettings, type PdfSettings, - type WorkspaceCommandTrust, -} from "../api"; + type WorkspaceCommandTrust, setIntentAnalysis } from "../api"; import { cancelDictationModelDownload, deleteDictationModel, @@ -473,6 +472,7 @@ function AppearanceSection() { + @@ -879,6 +879,46 @@ function ContextBarCard() { // to the composer's mode picker, plus its shadow-evaluation sibling. Both default off and are // user-global (a cloned repo can't turn either on). Shadow is nested under the main flag — it // only makes sense to measure the reviewer once you know what it is. +// Approval-prompt intent analysis (this PR): the model explains, in a couple of plain +// bullets, what an operation will do right on the approval card. Off by default; the +// annotation language follows the UI language at toggle time (and re-syncs when the +// UI language changes — see the languageChanged listener in main.tsx). +function IntentAnalysisCard() { + const { t, i18n } = useTranslation(); + const [enabled, setEnabled] = useState(null); + + useEffect(() => { + getSettings() + .then((s) => setEnabled(s.intent_analysis === true)) + .catch(() => setEnabled(false)); + }, []); + + const save = async (next: boolean) => { + setEnabled(next); + await setIntentAnalysis(next, i18n.language || "en"); + }; + + if (enabled === null) return null; + return ( +
+
{t("settings.intent_analysis_title")}
+ +
+ ); +} + function AutoApproveCard() { const [on, setOn] = useState(null); const [shadow, setShadow] = useState(false); diff --git a/surfaces/gui/src/locales/en.json b/surfaces/gui/src/locales/en.json index 25cbc26028..f1b4b9e838 100644 --- a/surfaces/gui/src/locales/en.json +++ b/surfaces/gui/src/locales/en.json @@ -385,7 +385,10 @@ "personas_desc": "Coworkers are agents specialized for a particular role or task. They come equipped with the tools and skills to be successful in that role. Enabling a coworker lets you pick it when starting a conversation.", "composer_section": "Composer", "context_bar_title": "Show the context window bar", - "context_bar_desc": "A small meter showing how full the model’s context window is. Turn it off to show the same thing as a number instead." + "context_bar_desc": "A small meter showing how full the model’s context window is. Turn it off to show the same thing as a number instead.", + "intent_analysis_title": "Explain commands before I approve them", + "intent_analysis_label": "AI command explanations on approval prompts", + "intent_analysis_desc": "Before a command asks for approval, the model adds two plain-language bullets explaining what it will do — so you can decide even if you can't read the command. One extra model call per approval prompt." }, "cloud": { "check_browser": "Check your browser…", diff --git a/surfaces/gui/src/locales/zh.json b/surfaces/gui/src/locales/zh.json index 65c3389d9b..cb5a5e9e03 100644 --- a/surfaces/gui/src/locales/zh.json +++ b/surfaces/gui/src/locales/zh.json @@ -377,7 +377,10 @@ "personas_desc": "同事是专精于特定角色或任务的 agent,自带胜任该角色所需的工具与技能。启用同事后,即可在开始会话时选择它。", "composer_section": "输入框", "context_bar_title": "显示上下文窗口占用条", - "context_bar_desc": "一个小指示条,显示模型上下文窗口的占用程度。关闭后将改为以数字形式显示。" + "context_bar_desc": "一个小指示条,显示模型上下文窗口的占用程度。关闭后将改为以数字形式显示。", + "intent_analysis_title": "审批前解释命令意图", + "intent_analysis_label": "审批卡上的 AI 命令解释", + "intent_analysis_desc": "命令请求批准前,模型会用两条通俗的要点说明它将做什么——即使看不懂命令也能做出判断。每次审批提示额外消耗一次模型调用。" }, "cloud": { "check_browser": "请查看你的浏览器…", diff --git a/surfaces/gui/src/main.tsx b/surfaces/gui/src/main.tsx index 1b70386406..f977416c2f 100644 --- a/surfaces/gui/src/main.tsx +++ b/surfaces/gui/src/main.tsx @@ -26,3 +26,16 @@ initI18n().finally(() => { , ); }); + +// Intent analysis (this PR): the approval annotation language must follow the UI +// language. When the user switches languages with the feature on, re-post the pref. +import { setIntentAnalysis } from "./api"; +import { getSettings } from "./api"; +import i18n from "i18next"; +i18n.on("languageChanged", (lng) => { + getSettings() + .then((s) => { + if (s.intent_analysis) return setIntentAnalysis(true, lng || "en"); + }) + .catch(() => {}); +}); diff --git a/surfaces/gui/src/styles.css b/surfaces/gui/src/styles.css index db0f5fcea4..f8bc650c41 100644 --- a/surfaces/gui/src/styles.css +++ b/surfaces/gui/src/styles.css @@ -2059,3 +2059,34 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color: font-family: var(--mono); font-size: var(--fs-mono); line-height: 1.5; color: var(--muted); white-space: pre-wrap; text-align: left; } + +/* Approval-prompt intent annotation (this PR): a restrained explanation block between + the command preview and the buttons — transparent background, small dot, secondary + grey, bolded critical terms. */ +.approval-intent { + margin: 10px 0 2px; + padding: 0; + list-style: none; +} +.approval-intent li { + display: flex; + gap: 8px; + align-items: baseline; + padding: 2px 0; + color: var(--ink-2, #66707a); + font-size: 12.5px; + line-height: 1.45; +} +.approval-intent-dot { + flex: none; + width: 5px; + height: 5px; + border-radius: 9999px; + background: var(--accent, #8b5cf6); + opacity: 0.85; + transform: translateY(-2px); +} +.approval-intent-em { + color: var(--ink, #23282e); + font-weight: 600; +} diff --git a/surfaces/gui/src/types.ts b/surfaces/gui/src/types.ts index 475cfc5c86..836f1e9937 100644 --- a/surfaces/gui/src/types.ts +++ b/surfaces/gui/src/types.ts @@ -162,6 +162,10 @@ export type Item = // Server-classified: this shell command only reads locally, so the card may offer // the session-wide "Allow read-only commands" grant. readonlyOk?: boolean; + // Approval-prompt intent analysis (this PR): plain-language bullets explaining + // what the operation will do, generated by the session's own model right before + // the card is raised. Absent = feature off / analysis failed / timed out. + intent?: string; resolved?: ApprovalDecision; } | { diff --git a/tests/test_engine_intent.py b/tests/test_engine_intent.py new file mode 100644 index 0000000000..7c99690a56 --- /dev/null +++ b/tests/test_engine_intent.py @@ -0,0 +1,318 @@ +"""Engine + manager integration tests for approval-prompt intent analysis. + +Covers: DI plumbing, the synchronous analyze-before-emit branch (success / None / +timeout / raise / user-stop), the composition contract with the permission pipeline +(no card \u2192 no analysis), payload coexistence with the upstream fields, and the +manager/app wiring incl. the annotation-language pref. +""" +import asyncio +import tempfile +import time +from unittest.mock import MagicMock + +from coworker.engine import ApprovalOutcome, PermissionRequest, TurnEngine +from coworker.events import EventType +from coworker.permissions import PermissionEngine +from coworker.providers.base import ModelCapabilities, ToolCall +from coworker.tools import ToolRegistry +from coworker.tools.shell import shell_tools + + +# -- PermissionRequest + TurnEngine.__init__ -- + + +def test_permission_request_has_intent_field(): + """PermissionRequest has an optional intent field (default None).""" + req = PermissionRequest(tool_name="run_shell", arguments={}, metadata=None, reason="test") + assert req.intent is None + req2 = PermissionRequest( + tool_name="run_shell", arguments={}, metadata=None, reason="test", intent="\u2022 x" + ) + assert req2.intent == "\u2022 x" + + +def test_turn_engine_accepts_intent_analyzer_none(): + engine = _build_engine_with_shell() + assert engine.intent_analyzer is None + + +def test_turn_engine_accepts_intent_analyzer_callable(): + analyzer = lambda tc, p, m: "\u2022 test" + engine = _build_engine_with_shell(intent_analyzer=analyzer) + assert engine.intent_analyzer is analyzer + + +def test_turn_engine_accepts_intent_analyzer_timeout(): + """Default 20.0; injectable for tests.""" + engine = _build_engine_with_shell() + assert engine.intent_analyzer_timeout == 20.0 + engine2 = _build_engine_with_shell(analyzer_timeout=0.1) + assert engine2.intent_analyzer_timeout == 0.1 + + +# -- _authorize: analyze only when a card will actually be shown -- + + +async def test_no_analyzer_payload_intent_none(): + """intent_analyzer=None \u2192 payload.intent is None (feature off = upstream behavior).""" + events = await _run_authorize() + ev = _perm_event(events) + assert ev.data["intent"] is None + + +async def test_analyzer_success_payload_has_intent(): + def analyzer(tc, p, m): + return "\u2022 dangerous\n\u2022 irreversible" + + events = await _run_authorize(intent_analyzer=analyzer) + ev = _perm_event(events) + assert "dangerous" in ev.data["intent"] + + +async def test_intent_coexists_with_upstream_payload_fields(): + """The intent annotation rides alongside the upstream card payload (readonly_ok, + provenance, standing_target) without disturbing them.""" + def analyzer(tc, p, m): + return "\u2022 reads files" + + events = await _run_authorize(intent_analyzer=analyzer, command="ls -la") + ev = _perm_event(events) + assert ev.data["intent"] == "\u2022 reads files" + # upstream's own fields are intact on the same event + assert "readonly_ok" in ev.data + assert ev.data["readonly_ok"] is True # ls -la classifies read-only + + +async def test_timeout_degrades_to_none_no_crash(): + """wait_for timeout must not crash _authorize; the card renders unannotated.""" + + def slow_analyzer(tc, p, m): + time.sleep(0.3) + return "never" + + events = await _run_authorize(intent_analyzer=slow_analyzer, analyzer_timeout=0.1) + ev = _perm_event(events) + assert ev.data["intent"] is None + + +async def test_analyzer_raises_returns_none(): + def bad_analyzer(tc, p, m): + raise RuntimeError("boom") + + events = await _run_authorize(intent_analyzer=bad_analyzer) + ev = _perm_event(events) + assert ev.data["intent"] is None + + +async def test_no_card_no_analysis(): + """Composition contract: when the permission pipeline resolves the call WITHOUT a + card (here: a standing allow rule), the analyzer must never run \u2014 its round + trip is only ever spent on a card the human will actually see.""" + calls = [] + + def analyzer(tc, p, m): + calls.append(tc.name) + return "\u2022 x" + + engine = _build_engine_with_shell(intent_analyzer=analyzer) + engine.permissions.allow_tool_for_session("run_shell") # standing allow \u2192 no card + items = await _collect_raw(engine, _tool_call()) + assert calls == [] # analyzer never invoked + assert not [i for i in items if hasattr(i, "type") and i.type == EventType.PERMISSION_REQUIRED] + # _authorize yields True to hand the call to the execution loop + assert any(item is True for item in items) + + +async def test_stop_before_emit_no_card(): + """A Stop that lands before the card is emitted must not flash the card.""" + engine = _build_engine_with_shell(intent_analyzer=lambda *a: None) + engine._cancel.set() + events = await _collect(engine, _tool_call()) + assert not [e for e in events if e.type == EventType.PERMISSION_REQUIRED] + + +async def test_stop_mid_analysis_no_card(): + """Stopping mid-analysis also avoids the flash \u2014 _interruptible resolves via + its cancel path and the post-analysis guard routes to the interrupted denial.""" + + def slow_analyzer(tc, p, m): + time.sleep(0.3) + return "never" + + engine = _build_engine_with_shell(intent_analyzer=slow_analyzer, analyzer_timeout=1.0) + + async def cancel_after_start(): + await asyncio.sleep(0.05) + engine._cancel.set() + + task = asyncio.get_running_loop().create_task(cancel_after_start()) + events = await _collect(engine, _tool_call()) + await task + assert not [e for e in events if e.type == EventType.PERMISSION_REQUIRED] + + +# -- build_engine passthrough -- + + +def test_build_engine_passes_intent_analyzer(): + from coworker.agent import build_engine + from coworker.agents import code_agent + + analyzer = lambda *a: "test" + engine = build_engine(agent=code_agent(), workspace=".", intent_analyzer=analyzer) + assert engine.intent_analyzer is analyzer + + +def test_build_engine_default_intent_analyzer_none(): + from coworker.agent import build_engine + from coworker.agents import code_agent + + engine = build_engine(agent=code_agent(), workspace=".") + assert engine.intent_analyzer is None + + +# -- manager: pref, language, carry-through -- + + +def test_get_settings_default_off(): + import tempfile + + from coworker.server.manager import SessionManager + + with tempfile.TemporaryDirectory() as tmp: + assert SessionManager(data_dir=tmp).get_settings()["intent_analysis"] is False + + +def test_manager_injects_analyzer_when_pref_on_with_language(tmp_path): + """Pref on \u2192 engines get an analyzer whose prompt language follows the stored + annotation language (here: zh).""" + from coworker.server.manager import SessionManager + + with tempfile.TemporaryDirectory() as tmp: + manager = SessionManager(data_dir=tmp) + manager.set_intent_analysis(True, language="zh") + engine = manager.get_engine("s1", agent="cowork", workspace=str(tmp_path)) + assert engine.intent_analyzer is not None + # the wrapper binds the language: run it against a recording provider + prov = MagicMock() + prov.complete.return_value = MagicMock(text="\u2022 ok") + engine.intent_analyzer(_tool_call(), prov, "m") + system = prov.complete.call_args.kwargs["messages"][0]["content"] + assert "\u4e25\u683c\u8f93\u51fa\u4e2d\u6587" in system + + +def test_manager_no_analyzer_when_pref_off(tmp_path): + from coworker.server.manager import SessionManager + + with tempfile.TemporaryDirectory() as tmp: + manager = SessionManager(data_dir=tmp) + engine = manager.get_engine("s1", agent="cowork", workspace=str(tmp_path)) + assert engine.intent_analyzer is None + + +def test_approval_prompt_data_carries_intent(): + """Parked (Inbox) approvals keep the annotation across reconnects.""" + import tempfile + + from coworker.server.manager import SessionManager + + with tempfile.TemporaryDirectory() as tmp: + manager = SessionManager(data_dir=tmp) + req = PermissionRequest( + tool_name="run_shell", + arguments={"command": "rm x"}, + metadata=None, + reason="test", + intent="\u2022 dangerous\n\u2022 irreversible", + ) + data = manager.approval_prompt_data("session-1", req) + assert data["intent"] == "\u2022 dangerous\n\u2022 irreversible" + + +def test_approval_prompt_data_no_intent_omits_field(): + import tempfile + + from coworker.server.manager import SessionManager + + with tempfile.TemporaryDirectory() as tmp: + manager = SessionManager(data_dir=tmp) + req = PermissionRequest( + tool_name="run_shell", arguments={}, metadata=None, reason="test", intent=None + ) + data = manager.approval_prompt_data("session-1", req) + assert "intent" not in data + + +def test_rest_roundtrip_including_language(): + """POST persists flag + language; GET reflects both.""" + import tempfile + + from fastapi.testclient import TestClient + + from coworker.server.app import create_app + from coworker.server.manager import SessionManager + + with tempfile.TemporaryDirectory() as tmp: + manager = SessionManager(data_dir=tmp) + client = TestClient(create_app(manager)) + assert client.get("/v1/settings").json()["intent_analysis"] is False + r = client.post( + "/v1/settings/intent-analysis", json={"enabled": True, "language": "zh"} + ) + assert r.json()["intent_analysis"] is True + assert r.json()["intent_analysis_lang"] == "zh" + settings = client.get("/v1/settings").json() + assert settings["intent_analysis"] is True + assert settings["intent_analysis_lang"] == "zh" + + +# -- helpers -- + + +def _tool_call(command="rm x"): + return ToolCall(id="tc1", name="run_shell", arguments={"command": command}) + + +def _build_engine_with_shell(intent_analyzer=None, analyzer_timeout=None): + registry = ToolRegistry() + registry.register_all(shell_tools(MagicMock())) + kwargs = dict( + provider=MagicMock(), + registry=registry, + permissions=PermissionEngine(workspace_root="."), + model="test", + intent_analyzer=intent_analyzer, + ) + if analyzer_timeout is not None: + kwargs["intent_analyzer_timeout"] = analyzer_timeout + return TurnEngine(**kwargs) + + +async def _collect(engine, tool_call): + async def deny(req): + return ApprovalOutcome.DENY + + engine.approver = deny + return [item for item in await _collect_raw(engine, tool_call) if hasattr(item, "type")] + + +async def _collect_raw(engine, tool_call): + async def deny(req): + return ApprovalOutcome.DENY + + engine.approver = deny + out = [] + async for item in engine._authorize(tool_call): + out.append(item) # both Events and the True/False flow signals + return out + + +async def _run_authorize(intent_analyzer=None, analyzer_timeout=None, command="rm x"): + engine = _build_engine_with_shell( + intent_analyzer=intent_analyzer, analyzer_timeout=analyzer_timeout + ) + return await _collect(engine, _tool_call(command)) + + +def _perm_event(events): + return next(e for e in events if e.type == EventType.PERMISSION_REQUIRED) diff --git a/tests/test_intent_analysis.py b/tests/test_intent_analysis.py new file mode 100644 index 0000000000..fce3bbd30b --- /dev/null +++ b/tests/test_intent_analysis.py @@ -0,0 +1,192 @@ +"""Tests for the AI command intent analysis module.""" +from unittest.mock import MagicMock + +from coworker.intent_analysis.analyzer import analyze, extract_input, _clean +from coworker.intent_analysis.prompts import ( + build_system_prompt, + build_user_prompt, + MAX_INPUT_CHARS, + MAX_BULLETS, +) + + +def _tc(name, **args): + """Build a duck-typed tool_call fixture.""" + tc = MagicMock() + tc.name = name + tc.arguments = args + return tc + + +# -- prompts -- + + +def test_build_system_prompt_has_rules(): + s = build_system_prompt("en", 2) + assert "bullet" in s.lower() + assert "1-2" in s # max_bullets is injected + + +def test_build_system_prompt_default_language_is_english(): + """Unknown/empty language falls back to English — upstream's default.""" + assert build_system_prompt("", 2) == build_system_prompt("en", 2) + assert build_system_prompt(None, 2) == build_system_prompt("en", 2) + assert build_system_prompt("fr", 2) == build_system_prompt("en", 2) + + +def test_build_system_prompt_language_templates(): + """zh selects the Chinese template; the two templates are genuinely different.""" + en = build_system_prompt("en", 2) + zh = build_system_prompt("zh", 2) + assert zh != en + assert "严格输出中文" in zh + assert "STRICTLY output in English" in en + # zh-CN style codes count as Chinese too + assert build_system_prompt("zh-CN", 2) == zh + + +def test_build_system_prompt_clamps_bullets(): + s = build_system_prompt("en", MAX_BULLETS + 10) + assert f"1-{MAX_BULLETS}" in s # clamped to the cap + + +def test_build_system_prompt_clamps_lower_bound(): + s = build_system_prompt("en", 0) + assert "1-1" in s # clamped to 1 + s2 = build_system_prompt("en", -5) + assert "1-1" in s2 + + +def test_build_user_prompt_includes_input(): + u = build_user_prompt("rm -rf /tmp") + assert "rm -rf /tmp" in u + assert "Return only the bullet points" in u + + +def test_build_user_prompt_truncates_long_input(): + long = "x" * (MAX_INPUT_CHARS + 50) + u = build_user_prompt(long) + assert len(u) < len(long) + 200 # truncated + + +def test_build_user_prompt_none_is_safe(): + """None/empty input must not raise (defensive guard).""" + u = build_user_prompt(None) + assert "Return only the bullet points" in u + + +# -- extract_input -- + + +def test_extract_input_shell(): + tc = _tc("run_shell", command="rm -rf /tmp") + assert extract_input(tc) == "rm -rf /tmp" + + +def test_extract_input_file_write(): + tc = _tc("write_file", path="/etc/config") + out = extract_input(tc) + assert "write_file" in out and "/etc/config" in out + + +def test_extract_input_replace_in_file(): + tc = _tc("replace_in_file", path="/app/main.py") + out = extract_input(tc) + assert "replace_in_file" in out + + +def test_extract_input_send_message_target(): + """send_message's real param is 'target', not 'destination'/'channel'.""" + tc = _tc("send_message", target="slack:#general", text="hello") + out = extract_input(tc) + assert "slack:#general" in out and "hello" in out + + +def test_extract_input_fallback(): + tc = _tc("unknown_tool", foo="bar") + out = extract_input(tc) + assert "unknown_tool" in out and "foo" in out + + +# -- _clean -- + + +def test_clean_valid_bullets(): + assert _clean("• deletes file\n• unrecoverable") == "• deletes file\n• unrecoverable" + + +def test_clean_strips_fences(): + assert _clean("```\n• deletes file\n```") == "• deletes file" + + +def test_clean_strips_leading_intent_label(): + assert _clean("Intent:\n• deletes file") == "• deletes file" + + +def test_clean_empty_returns_none(): + assert _clean("") is None + assert _clean("no bullets here") is None + + +# -- analyze (positional signature) -- + + +def test_analyze_positional_signature(): + """analyze(tc, prov, mdl) must accept positional args (engine calls it via + asyncio.to_thread(self.intent_analyzer, tool_call, provider, model)).""" + prov = MagicMock() + prov.complete.return_value = MagicMock(text="• ok") + result = analyze(_tc("run_shell", command="ls"), prov, "test-model") + assert result == "• ok" + + +def test_analyze_default_language_is_english(): + prov = MagicMock() + prov.complete.return_value = MagicMock(text="• ok") + analyze(_tc("run_shell", command="ls"), prov, "m") + system = prov.complete.call_args.kwargs["messages"][0]["content"] + assert "STRICTLY output in English" in system + + +def test_analyze_language_selects_template(): + prov = MagicMock() + prov.complete.return_value = MagicMock(text="• ok") + analyze(_tc("run_shell", command="ls"), prov, "m", language="zh") + system = prov.complete.call_args.kwargs["messages"][0]["content"] + assert "严格输出中文" in system + + +def test_analyze_single_turn_tools_disabled(): + """Mirrors the compaction summarize_span pattern: one completion, no tools.""" + prov = MagicMock() + prov.complete.return_value = MagicMock(text="• ok") + analyze(_tc("run_shell", command="ls"), prov, "m") + assert prov.complete.call_count == 1 + assert prov.complete.call_args.kwargs["tools"] is None + + +def test_analyze_non_bullet_output_returns_none(): + """A model that answers in prose (no bullet lines) degrades to None — the card + simply renders without the annotation.""" + prov = MagicMock() + prov.complete.return_value = MagicMock(text="This will delete the file.") + assert analyze(_tc("run_shell", command="rm x"), prov, "m") is None + + +def test_analyze_success(): + prov = MagicMock() + prov.complete.return_value = MagicMock(text="• permanently deleted\n• unrecoverable") + result = analyze(_tc("run_shell", command="rm x"), prov, "m") + assert "permanently deleted" in result + + +def test_analyze_provider_error_returns_none(): + prov = MagicMock() + prov.complete.side_effect = RuntimeError("network down") + assert analyze(_tc("run_shell", command="rm x"), prov, "m") is None + + +def test_analyze_empty_output_returns_none(): + prov = MagicMock() + prov.complete.return_value = MagicMock(text="") + assert analyze(_tc("run_shell", command="rm x"), prov, "m") is None