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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions coworker/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand Down
57 changes: 57 additions & 0 deletions coworker/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
{
Expand Down Expand Up @@ -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
Expand Down
Empty file.
91 changes: 91 additions & 0 deletions coworker/intent_analysis/analyzer.py
Original file line number Diff line number Diff line change
@@ -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
103 changes: 103 additions & 0 deletions coworker/intent_analysis/prompts.py
Original file line number Diff line number Diff line change
@@ -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."
8 changes: 8 additions & 0 deletions coworker/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading
Loading