Skip to content

Commit 1a602b1

Browse files
author
Chojan Shang
committed
feat: add contrib for some cases
Signed-off-by: Chojan Shang <chojan.shang@vesoft.com>
1 parent 9f499d2 commit 1a602b1

File tree

9 files changed

+962
-0
lines changed

9 files changed

+962
-0
lines changed

docs/contrib.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Experimental Contrib Modules
2+
3+
> The helpers under `acp.contrib` capture patterns we observed in reference integrations such as Toad and kimi-cli. Every API here is experimental and may change without notice.
4+
5+
## SessionAccumulator
6+
7+
Module: `acp.contrib.session_state`
8+
9+
UI surfaces like Toad need a live, merged view of the latest tool calls, plan entries, and message stream. The core SDK only emits raw `SessionNotification` payloads, so applications usually end up writing their own state layer. `SessionAccumulator` offers that cache out of the box.
10+
11+
Capabilities:
12+
13+
- `SessionAccumulator.apply(notification)` merges `tool_call` and `tool_call_update` events, backfilling a missing start message when necessary.
14+
- Each call to `snapshot()` returns an immutable `SessionSnapshot` (Pydantic model) containing the active plan, current mode ID, available commands, and historical user/agent/thought chunks.
15+
- `subscribe(callback)` wires a lightweight observer that receives every new snapshot, making it easy to refresh UI widgets.
16+
- Automatic reset when a different session ID arrives (configurable via `auto_reset_on_session_change`).
17+
18+
> Integration tip: create one accumulator per UI controller. Feed every `SessionNotification` through it, then render from `snapshot.tool_calls` or `snapshot.user_messages` instead of mutating state manually.
19+
20+
## ToolCallTracker & PermissionBroker
21+
22+
Modules: `acp.contrib.tool_calls` and `acp.contrib.permissions`
23+
24+
Agent-side runtimes (for example kimi-cli) are responsible for synthesising tool call IDs, streaming argument fragments, and formatting permission prompts. Managing bare Pydantic models quickly devolves into boilerplate; these helpers centralise the bookkeeping.
25+
26+
- `ToolCallTracker.start()/progress()/append_stream_text()` manages tool call state and emits canonical `ToolCallStart` / `ToolCallProgress` messages. The tracker also exposes `view()` (immutable `TrackedToolCallView`) and `tool_call_model()` for logging or permission prompts.
27+
- `PermissionBroker.request_for()` wraps `requestPermission` RPCs. It reuses the tracker’s state (or an explicit `ToolCall`), applies optional extra content, and defaults to a standard Approve / Approve for session / Reject option set.
28+
- `default_permission_options()` exposes that canonical option triple so applications can customise or extend it.
29+
30+
> Integration tip: keep a single tracker alongside your agent loop. Emit tool call notifications through it, and hand the tracker to `PermissionBroker` so permission prompts stay in sync with the latest call state.
31+
32+
## Design Guardrails
33+
34+
To stay aligned with the ACP schema, the contrib layer follows a few rules:
35+
36+
- Protocol types continue to live in `acp.schema`. Contrib code always copies them via `.model_copy(deep=True)` to avoid mutating shared instances.
37+
- Helpers are opt-in; the core package never imports them implicitly and imposes no UI or agent framework assumptions.
38+
- Implementations focus on the common pain points (tool call aggregation, permission requests) while leaving business-specific policy to the application.
39+
40+
Try the contrib modules in your agent or client, and open an issue/PR with feedback so we can decide which pieces should graduate into the stable surface.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ copyright: Maintained by <a href="https://github.com/psiace">psiace</a>.
1010
nav:
1111
- Home: index.md
1212
- Quick Start: quickstart.md
13+
- Experimental Contrib: contrib.md
1314
- Releasing: releasing.md
1415
plugins:
1516
- search

src/acp/contrib/__init__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""
2+
Experimental helpers for Agent Client Protocol integrations.
3+
4+
Everything exposed from :mod:`acp.contrib` is considered unstable and may change
5+
without notice. These modules are published to share techniques observed in the
6+
reference implementations (for example Toad or kimi-cli) while we continue
7+
refining the core SDK surface.
8+
9+
The helpers live in ``acp.contrib`` so consuming applications must opt-in
10+
explicitly, making it clear that the APIs are incubating.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from .permissions import PermissionBroker, default_permission_options
16+
from .session_state import SessionAccumulator, SessionSnapshot, ToolCallView
17+
from .tool_calls import ToolCallTracker, TrackedToolCallView
18+
19+
__all__ = [
20+
"PermissionBroker",
21+
"SessionAccumulator",
22+
"SessionSnapshot",
23+
"ToolCallTracker",
24+
"ToolCallView",
25+
"TrackedToolCallView",
26+
"default_permission_options",
27+
]

src/acp/contrib/permissions.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Awaitable, Callable, Sequence
4+
from typing import Any
5+
6+
from ..helpers import text_block, tool_content
7+
from ..schema import PermissionOption, RequestPermissionRequest, RequestPermissionResponse, ToolCall
8+
from .tool_calls import ToolCallTracker, _copy_model_list
9+
10+
11+
class PermissionBrokerError(ValueError):
12+
"""Base error for permission broker misconfiguration."""
13+
14+
15+
class MissingToolCallError(PermissionBrokerError):
16+
"""Raised when a permission request is missing the referenced tool call."""
17+
18+
def __init__(self) -> None:
19+
super().__init__("tool_call must be provided when no ToolCallTracker is configured")
20+
21+
22+
class MissingPermissionOptionsError(PermissionBrokerError):
23+
"""Raised when no permission options are available for a request."""
24+
25+
def __init__(self) -> None:
26+
super().__init__("PermissionBroker requires at least one permission option")
27+
28+
29+
def default_permission_options() -> tuple[PermissionOption, PermissionOption, PermissionOption]:
30+
"""Return a standard approval/reject option set."""
31+
return (
32+
PermissionOption(optionId="approve", name="Approve", kind="allow_once"),
33+
PermissionOption(optionId="approve_for_session", name="Approve for session", kind="allow_always"),
34+
PermissionOption(optionId="reject", name="Reject", kind="reject_once"),
35+
)
36+
37+
38+
class PermissionBroker:
39+
"""Helper for issuing permission requests tied to tracked tool calls."""
40+
41+
def __init__(
42+
self,
43+
session_id: str,
44+
requester: Callable[[RequestPermissionRequest], Awaitable[RequestPermissionResponse]],
45+
*,
46+
tracker: ToolCallTracker | None = None,
47+
default_options: Sequence[PermissionOption] | None = None,
48+
) -> None:
49+
self._session_id = session_id
50+
self._requester = requester
51+
self._tracker = tracker
52+
self._default_options = tuple(
53+
option.model_copy(deep=True) for option in (default_options or default_permission_options())
54+
)
55+
56+
async def request_for(
57+
self,
58+
external_id: str,
59+
*,
60+
description: str | None = None,
61+
options: Sequence[PermissionOption] | None = None,
62+
content: Sequence[Any] | None = None,
63+
tool_call: ToolCall | None = None,
64+
) -> RequestPermissionResponse:
65+
"""Request user approval for a tool call."""
66+
if tool_call is None:
67+
if self._tracker is None:
68+
raise MissingToolCallError()
69+
tool_call = self._tracker.tool_call_model(external_id)
70+
else:
71+
tool_call = tool_call.model_copy(deep=True)
72+
73+
if content is not None:
74+
tool_call.content = _copy_model_list(content)
75+
76+
if description:
77+
existing = tool_call.content or []
78+
existing.append(tool_content(text_block(description)))
79+
tool_call.content = existing
80+
81+
option_set = tuple(option.model_copy(deep=True) for option in (options or self._default_options))
82+
if not option_set:
83+
raise MissingPermissionOptionsError()
84+
85+
request = RequestPermissionRequest(
86+
sessionId=self._session_id,
87+
toolCall=tool_call,
88+
options=list(option_set),
89+
)
90+
return await self._requester(request)
91+
92+
93+
__all__ = [
94+
"PermissionBroker",
95+
"default_permission_options",
96+
]

0 commit comments

Comments
 (0)