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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ Instrument critical-path code (RunLoop, Turn, delegation, protocol entry points)
| Lifecycle dimensions (full) | `docs/explanation/lifecycle-dimensions.md` |
| Hooks & events (full) | `docs/explanation/hooks-events.md` |
| Capabilities (full) | `docs/explanation/capabilities.md` |
| ToolDisplayCapability (tool rename + diff events) | `docs/explanation/tool-display-capability.md` |
| Telemetry rules | `docs/explanation/telemetry.md` |
| Usage examples | `docs/explanation/usage-examples.md` |
| Extending AgentPool | `docs/explanation/extending-agentpool.md` |
Expand Down
76 changes: 76 additions & 0 deletions docs/explanation/tool-display-capability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# ToolDisplayCapability

`ToolDisplayCapability` 是 agentpool 的**全局装饰器能力** (Global Decorator Capability):在不修改任何子能力源码的前提下,对 agent 已装配的全部工具统一起作用 —— **改名** (`rename_mode`) 与 **注入 diff 富信息事件** (`emit_diff`)。它解决的核心问题是:第三方 capability(如 viking)的工具名不在协议客户端(OpenCode TUI / Zed)的渲染白名单内、工具返回不含 diff 富信息,导致客户端无法渲染文件变更的 diff 视图。

模式对齐 `ToolInterceptCapability`(`src/agentpool/agents/native_agent/tool_intercept.py`):独立 `AbstractCapability` 直接覆写 `get_wrapper_toolset()` 与 `wrap_tool_execute()`,作为全局中间件横切 agent 的全部工具 —— 不组合子能力、无 `capabilities` 字段。

## 三个正交开关

| 开关 | 默认 | 作用 |
|---|---|---|
| `rename_mode: bool` | `true` | 启用工具改名。经 `get_wrapper_toolset()` 返回 pydantic-ai 官方 `RenamedToolset(wrapped=toolset, name_map=...)`,按 `name_map` 重写 `tool_def.name`,执行时自动还原 `ctx.tool_name`。`name_map` 为空或 `rename_mode: false` 时不包装 |
| `emit_diff: bool` | `true` | 启用 diff 事件注入。`wrap_tool_execute()` 在工具真实执行后,对命中的工具注入 `ToolCallProgressEvent.file_edit(...)`(携带 `DiffContentItem`) |
| `emit_diff_for: set[str]` | `set()`(空=不注入) | 选择注入白名单,**按工具名精确过滤**。仅当 `emit_diff: true` 且工具名在名单内时注入 |

`name_map` 与 `emit_diff_for` 都为空时,退化为**无操作装饰器**:`get_wrapper_toolset` 返回 `None`,`wrap_tool_execute` 直接透传。

## Diff 数据来源(执行后注入)

`wrap_tool_execute` 先调用 `handler(args)` **拿到真实执行结果**,再从 `args` 解析 diff 字段(模块级 `_parse_diff_fields` 辅助):

- **write 风格** (工具入参含 `content`/`path`|`uri`):`new_text=content`、`old_text=None`(视为新增文件)
- **edit 风格** (工具入参含 `old_string` + `new_string`):`old_text=old_string`、`new_text=new_string`
- **退化兜底**:path 无法从入参解析、或 new_text 为空时,跳过注入且不报错

路径键取自 `path` / `file_path` / `uri` / `filepath` 的任意现值(按序取第一个),因此对 viking 的 `viking://...` URI 与本地文件路径同样有效。

## 事件注入通道

注入的 `ToolCallProgressEvent` + `DiffContentItem` 流经既有管道,零协议改动:

```
wrap_tool_execute → ctx.deps.events.tool_call_progress(title, items=[DiffContentItem(...)])
→ EventBus publish → EventMapper._is_rich_event 原样透传
→ ACP 转换器 DiffContentItem → FileEditToolCallContent + ToolCallLocation
→ 客户端(Zed/OpenCode TUI)渲染 diff
```

- `ctx.deps` 在 agentpool 中**直接是 `AgentContext`**,携带 `.events` → `StreamEventEmitter`(POC 已验证,同 fsspec 工具集 `agentpool_toolsets/fsspec_toolset/toolset.py:575` 的先例通道)
- `DiffContentItem(path, old_text, new_text)` 定义于 `src/agentpool/agents/events/events.py:203`
- **不依赖 metadata 通道**:`ToolReturn.metadata` 在 `process_tool_event`/`event_mapper` 构造 `ToolCallCompleteEvent` 时会被丢弃(仅 `is_error`),本能力刻意绕开该断点,改用事件注入

## 协议区分配置

同一工具集在不同协议客户端下的展示诉求不同,通过**装配期**配置区分(零运行时协议标识改动):

| 场景 | 配置 | 说明 |
|---|---|---|
| **OpenCode TUI** | `rename_mode: true` + `emit_diff: true` | TUI 按工具名白名单渲染 —— 改名命中白名单(`viking_write`→`write`)+ diff 注入 |
| **ACP (Zed)** | `rename_mode: false` + `emit_diff: true` | Zed 展示原名即可,`FileEditToolCallContent` 原生渲染差异 |
| **子能力已自发射** (fsspec 模式) | `rename_mode: true` + `emit_diff: false` | 子能力已自行 emit `DiffContentItem`,装饰器仅改名,避免重复注入 |

**防重复原则**:子 capability 已自行发射 diff 事件的场景,必须用 `emit_diff: false`,否则同一变更被注入两次。

## 配置与注册

```yaml
# agent YAML capabilities 段 —— 与其它 capability 平级列出即可(全局中间件)
capabilities:
- type: tool_display
args:
rename_mode: true
name_map:
viking_write: write
viking_edit: edit
emit_diff: true
emit_diff_for: [viking_write, viking_edit]
```

- 注册:entry-point 组 `agentpool.capabilities`,key `tool_display` → `agentpool.capabilities.tool_display_capability:ToolDisplayCapability`(见 `pyproject.toml`),由 `registry.py` 发现
- 构造:`EntryPointCapabilityConfig(type=..., args={...}).build()` 以 `cls(**args)` 实例化 —— dataclass 字段(`rename_mode`/`name_map`/`emit_diff`/`emit_diff_for`)+ `id` 天然兼容 YAML 装配

## 已知约束

- **`ctx.tool_name` 双名不一致**:改名后,事件映射层携带新名、工具自发射事件携带原名 —— 注入事件显式构造 `tool_call_id`,不依赖名称匹配
- **只映射语义等价的标准名**:改名可能触发客户端内置行为(如 `write` 触发生成式 diff),只映射语义一致的工具,映射表见配置文档
- **仅上游通道**:本能力不打通 OpenCode TUI 的 last-turn DiffViewer(远端写入无法被本地 git snapshot 捕获)—— 如需另立 change
66 changes: 66 additions & 0 deletions docs/tool-display-capability.example.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# ToolDisplayCapability 配置示例
#
# 全局装饰器:不改子能力源码,对 agent 已装配工具改名 + 注入 diff 富信息事件。
# 三个正交开关:rename_mode(改名)/ emit_diff(注入)/ emit_diff_for(注入白名单)。
# 详见 docs/explanation/tool-display-capability.md
---
# 示例 1:OpenCode TUI —— 改名命中客户端渲染白名单 + diff 注入
# viking_write/viking_edit 重命名为 write/edit,客户端按白名单渲染;
# 同时注入 DiffContentItem → TUI 显示文件变更 diff。
agents:
wiki-librarian-opencode:
model:
identifier: anthropic:claude-sonnet-4-5
capabilities:
- type: viking
mode: write
url: http://localhost:8765
- type: tool_display
args:
rename_mode: true
name_map:
viking_write: write
viking_edit: edit
emit_diff: true
emit_diff_for:
- viking_write
- viking_edit
---
# 示例 2:ACP (Zed) —— 展示原名 + diff 注入
# Zed 通过 FileEditToolCallContent 原生渲染差异,无需改名;
# 仅注入 diff 富信息事件。
agents:
wiki-librarian-acp:
model:
identifier: anthropic:claude-sonnet-4-5
capabilities:
- type: viking
mode: write
url: http://localhost:8765
- type: tool_display
args:
rename_mode: false
emit_diff: true
emit_diff_for:
- viking_write
- viking_edit
---
# 示例 3:子能力已自发射 —— 仅改名,关闭注入避免重复
# fsspec 等工具集已在内部 emit DiffContentItem(见
# src/agentpool_toolsets/fsspec_toolset/toolset.py:575),装饰器只负责
# 改名,emit_diff: false。
agents:
fs-writer:
model:
identifier: anthropic:claude-sonnet-4-5
tools:
- type: file_access
root: /data
capabilities:
- type: tool_display
args:
rename_mode: true
name_map:
fsspec_write: write
fsspec_edit: edit
emit_diff: false
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ subagent = "agentpool.capabilities.subagent_capability:SubagentCapability"
skill = "agentpool.capabilities.skill_manager_cap:SkillManagerCap"
combined = "agentpool.capabilities.combined_toolset:CombinedToolsetCapability"
code_mode = "agentpool.capabilities.code_mode_capability:CodeModeCapability"
tool_display = "agentpool.capabilities.tool_display_capability:ToolDisplayCapability"
question = "agentpool_toolsets.builtin.question_tools:QuestionTools"

[project.entry-points."fsspec.specs"]
Expand Down
233 changes: 233 additions & 0 deletions src/agentpool/capabilities/tool_display_capability.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
"""ToolDisplayCapability — global decorator for tool display names and diff-rich events.

A configurable ``AbstractCapability`` that decorates the agent's fully
assembled toolset without modifying any tool or capability:

- **Rename layer** (``rename_mode``): maps selected tool names to
display names via :class:`~pydantic_ai.toolsets.RenamedToolset`, so
protocol clients (e.g. the OpenCode TUI) that dispatch on a whitelist
of standard tool names render the tools properly.
- **Rich-info layer** (``emit_diff``): injects a
:class:`~agentpool.agents.events.DiffContentItem` progress event after
a matching tool executes, so ACP clients (e.g. Zed) render a file
diff.

The two layers are orthogonal: an OpenCode-facing deployment uses
``rename_mode=True + emit_diff=True``; an ACP-facing deployment uses
``rename_mode=False + emit_diff=True`` (original names displayed with
diffs); a child capability that already emits its own
``DiffContentItem`` uses ``rename_mode=True + emit_diff=False`` (rename
only, no duplicate diff).

Modeled on :class:`~agentpool.agents.native_agent.tool_intercept.ToolInterceptCapability`
— a standalone ``AbstractCapability`` overriding ``get_wrapper_toolset``
and ``wrap_tool_execute`` as a global middleware over all assembled
tools.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

import logfire
from pydantic_ai.capabilities import AbstractCapability
from pydantic_ai.toolsets import AbstractToolset, RenamedToolset

from agentpool.agents.events import DiffContentItem


if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Mapping

from pydantic_ai._run_context import RunContext
from pydantic_ai.messages import ToolCallPart
from pydantic_ai.tools import ToolDefinition


def _parse_diff_fields(
args: Mapping[str, Any], result: Any
) -> tuple[str | None, str | None, str | None]:
"""Extract (path, old_text, new_text) from tool call arguments and result.

Recognizes common parameter shapes across file-writing tools:

- ``path``/``file_path``/``uri`` → target path
- ``content`` (write-style) → new text, old text ``None`` (new file)
- ``old_string``/``new_string`` (edit-style) → old/new text pair

``result`` is inspected as a fallback when ``new_text`` cannot be
derived from arguments (e.g. a tool that returns the written content
as a string).

Args:
args: The validated tool call arguments.
result: The tool execution result.

Returns:
A ``(path, old_text, new_text)`` tuple with ``None`` values for
fields that could not be derived.
"""
path = next(
(
str(args[k])
for k in ("path", "file_path", "uri", "filepath")
if isinstance(args.get(k), str) and args[k]
),
None,
)
if path is None:
return (None, None, None)

old_text: str | None = None
new_text: str | None = None
if isinstance(args.get("old_string"), str) and isinstance(args.get("new_string"), str):
old_text = args["old_string"]
new_text = args["new_string"]
elif isinstance(args.get("content"), str):
new_text = args["content"]
elif isinstance(result, str) and result:
new_text = result

return (path, old_text, new_text)


@dataclass(kw_only=True)
class ToolDisplayCapability(AbstractCapability[Any]):
"""Global tool display decorator: rename tools + inject diff events.

Attributes:
rename_mode: Enable tool name mapping via ``name_map``. When
``False``, tools keep their native names (ACP-style display).
name_map: Mapping of **original** tool name to **display** name
(what the user writes in YAML). Internally inverted before
passing to ``RenamedToolset``, which expects ``{new: original}``.
emit_diff: Enable diff event injection after tool execution.
When ``False``, rely on tools' own diff emission.
emit_diff_for: Set of **original** tool names eligible for diff
event injection. Empty means no injection. When rename is
active, display names are resolved back to originals before
matching.
id: Optional capability id.
"""

id: str | None = None
rename_mode: bool = True
name_map: Mapping[str, str] = field(default_factory=dict)
emit_diff: bool = True
emit_diff_for: set[str] = field(default_factory=set)

def __post_init__(self) -> None:
"""Coerce ``emit_diff_for`` to ``set`` if a list was provided via YAML."""
if isinstance(self.emit_diff_for, list):
self.emit_diff_for = set(self.emit_diff_for)

@property
def _reverse_name_map(self) -> dict[str, str]:
"""Display → original lookup, derived from ``name_map`` (original → display)."""
return {v: k for k, v in self.name_map.items()}

def get_wrapper_toolset(self, toolset: AbstractToolset[Any]) -> AbstractToolset[Any] | None:
"""Wrap the assembled toolset with ``RenamedToolset`` when enabled.

``name_map`` is stored as ``{original: display}`` (user-facing
convention) but ``RenamedToolset`` expects ``{new: original}``,
so we invert before construction.

Args:
toolset: The agent's fully assembled toolset.

Returns:
A ``RenamedToolset`` applying ``name_map``, or ``None`` when
renaming is disabled or the map is empty (toolset unchanged).
"""
if not self.rename_mode or not self.name_map:
return None
# RenamedToolset.name_map is {new_name: original_name}.
# Our name_map is {original: display} → invert to {display: original}.
inverted = {v: k for k, v in self.name_map.items()}
return RenamedToolset(wrapped=toolset, name_map=inverted)

async def wrap_tool_execute(
self,
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
handler: Callable[[dict[str, Any]], Awaitable[Any]],
) -> Any:
"""Execute the tool, then inject a diff progress event when enabled.

After ``handler`` completes, derives ``(path, old_text, new_text)``
from the call arguments and emits a
:class:`~agentpool.agents.events.ToolCallProgressEvent` carrying a
:class:`~agentpool.agents.events.DiffContentItem` via the run
context's ``events`` emitter — the same channel fsspec tools use,
which reaches ACP converters as ``FileEditToolCallContent``.

Two critical steps before emitting:

1. **Populate ``ctx.deps.tool_call_id`` / ``tool_name``** —
capability tools (viking, fsspec, …) bypass
``tool_wrapping.py``, so these fields are ``None``. Without
them, ``StreamEventEmitter`` reads ``""`` and the ACP
converter drops the event (``if tool_call_id:`` guard fails).
2. **Resolve display → original name** — when rename is active,
``call.tool_name`` is the *display* name. ``emit_diff_for``
contains *original* names. We reverse-lookup through
``name_map`` before matching.

Args:
ctx: The pydantic-ai run context (carries ``deps`` → agentpool
``AgentContext`` with the ``events`` emitter).
call: The tool call part.
tool_def: The tool definition.
args: The validated tool call arguments.
handler: The wrapped tool execution callable.

Returns:
The tool execution result, unchanged.
"""
with logfire.span("capability.tool_display.wrap_tool_execute", tool_name=call.tool_name):
result = await handler(args)

if not self.emit_diff or not self.emit_diff_for:
return result

# Resolve display name → original for emit_diff_for matching.
# When rename is active, call.tool_name is the display name;
# emit_diff_for contains original names.
original_name = self._reverse_name_map.get(call.tool_name, call.tool_name)
if original_name not in self.emit_diff_for:
return result

path, old_text, new_text = _parse_diff_fields(args, result)
if path is None or new_text is None:
return result

# Populate ctx.deps.tool_call_id / tool_name for capability tools.
# tool_wrapping.py only does this for legacy direct tools (agent.py:1044-1052);
# capability tools (AbstractCapability) skip that path, leaving these as None.
# StreamEventEmitter reads self._context.tool_call_id → "" → ACP converter drops.
deps = ctx.deps
if hasattr(deps, "tool_call_id"):
deps.tool_call_id = call.tool_call_id
if hasattr(deps, "tool_name"):
deps.tool_name = original_name

events = getattr(deps, "events", None)
if events is None:
return result

await events.tool_call_progress(
title=f"Modified: {path}",
items=[
DiffContentItem(
path=path,
old_text=old_text,
new_text=new_text,
)
],
)
return result
Loading
Loading