Skip to content

Commit 88c59ee

Browse files
committed
feat(acp_zed): route io_confirm through session/request_permission
Wire the framework's existing `io_confirm` / `setup_io_context` ContextVar pattern into the ACP Zed adapter, mirroring how A2A and IBM/BeeAI-ACP already override it in their own scopes. Any agent code that calls `io_confirm(...)` — most notably `AskPermissionRequirement` — now delivers the prompt to the editor user via ACP's `session/request_permission` and maps `AllowedOutcome` / `DeniedOutcome` back to `bool`, instead of hanging on stdin (which is owned by the JSON-RPC transport under ACP). - `ACPZedIOContext` (new `adapters/acp_zed/serve/io.py`) installs `confirm` and `read` handlers on enter, restores on exit. - `FsBridge.request_permission` passthrough. - `ACPZedServerAgent.prompt` creates the turn task *inside* the io context so the ContextVar propagates to `run_turn` and any tool / requirement code it calls. - `io_read` raises a clear `RuntimeError` under the adapter — ACP has no free-form text-input method, and silently hanging on the transport stdin is worse than failing loudly. - 4 new tests: allow / deny round-trips, read-raises-under-context, and context teardown restoration. Signed-off-by: Tomáš Dvořák <toomas2d@gmail.com>
1 parent a2bb53d commit 88c59ee

3 files changed

Lines changed: 196 additions & 5 deletions

File tree

‎python/beeai_framework/adapters/acp_zed/serve/agent.py‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ async def release_terminal(self, terminal_id: str) -> None:
130130
async def kill_terminal(self, terminal_id: str) -> None:
131131
await self._require_conn().kill_terminal(session_id=self._active_session_id(), terminal_id=terminal_id)
132132

133+
async def request_permission(self, *, options: list[Any], tool_call: Any) -> Any:
134+
return await self._require_conn().request_permission(
135+
options=options, session_id=self._active_session_id(), tool_call=tool_call
136+
)
137+
133138

134139
class ACPZedServerAgent(ACPBaseAgent):
135140
"""Wrapper that exposes a BeeAI agent over the Zed Agent Client Protocol (stdio).
@@ -211,13 +216,21 @@ async def prompt(
211216
with contextlib.suppress(asyncio.CancelledError):
212217
await existing
213218

219+
# Lazy import to avoid an import cycle (io.py depends on FsBridge).
220+
from beeai_framework.adapters.acp_zed.serve.io import ACPZedIOContext
221+
214222
token = _active_session.set(session_id)
215-
task = asyncio.create_task(self._run_turn(session_id, prompt, self._conn, self._sessions[session_id]))
216-
self._active_tasks[session_id] = task
217223
try:
218-
stop_reason = await task
219-
except asyncio.CancelledError:
220-
stop_reason = "cancelled"
224+
with ACPZedIOContext(self._bridge):
225+
# Create the task *inside* the io context so the ContextVar propagates
226+
# to `run_turn` and any `io_confirm` calls made by tools / requirements
227+
# route through `session/request_permission`.
228+
task = asyncio.create_task(self._run_turn(session_id, prompt, self._conn, self._sessions[session_id]))
229+
self._active_tasks[session_id] = task
230+
try:
231+
stop_reason = await task
232+
except asyncio.CancelledError:
233+
stop_reason = "cancelled"
221234
finally:
222235
_active_session.reset(token)
223236
self._active_tasks.pop(session_id, None)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from __future__ import annotations
5+
6+
from collections.abc import Callable
7+
from typing import TYPE_CHECKING, Any, Self
8+
9+
from typing_extensions import Unpack
10+
11+
try:
12+
from acp.schema import AllowedOutcome, PermissionOption, ToolCallUpdate
13+
except ModuleNotFoundError as e:
14+
raise ModuleNotFoundError(
15+
"Optional module [acp_zed] not found.\nRun 'pip install \"beeai-framework[acp-zed]\"' to install."
16+
) from e
17+
18+
from beeai_framework.utils.io import IOConfirmKwargs, setup_io_context
19+
20+
if TYPE_CHECKING:
21+
from beeai_framework.adapters.acp_zed.serve.agent import FsBridge
22+
23+
24+
class ACPZedIOContext:
25+
"""Route `io_confirm` through ACP `session/request_permission` for the life of a turn.
26+
27+
Mirrors the A2A / IBM-ACP `setup_io_context` pattern: when this context is active,
28+
any agent code calling `io_confirm(...)` (e.g. `AskPermissionRequirement`) gets the
29+
prompt delivered to the editor user via `session/request_permission` instead of
30+
stdin, and the user's choice flows back as a `bool`.
31+
32+
`io_read` is overridden to raise — ACP has no free-form text input method; calling
33+
it under the adapter is a programmer error worth surfacing loudly rather than
34+
hanging on a stdin that's already owned by the JSON-RPC transport.
35+
"""
36+
37+
def __init__(self, bridge: FsBridge) -> None:
38+
self._bridge = bridge
39+
self._cleanup: Callable[[], None] = lambda: None
40+
41+
def __enter__(self) -> Self:
42+
# pyrefly: ignore [bad-argument-type]
43+
self._cleanup = setup_io_context(read=self._read, confirm=self._confirm)
44+
return self
45+
46+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
47+
self._cleanup()
48+
self._cleanup = lambda: None
49+
50+
async def _read(self, prompt: str) -> str:
51+
raise RuntimeError(
52+
"io_read is not supported under the ACPZed adapter — ACP has no free-form "
53+
"text-input method. Use a tool call or session/request_permission instead."
54+
)
55+
56+
async def _confirm(self, prompt: str, **kwargs: Unpack[IOConfirmKwargs]) -> bool:
57+
title = kwargs.get("title", prompt)
58+
data = kwargs.get("data", {})
59+
submit_label = kwargs.get("submit_label", "Allow")
60+
cancel_label = kwargs.get("cancel_label", "Deny")
61+
62+
tool_call = ToolCallUpdate(
63+
tool_call_id=f"confirm-{id(data)}",
64+
title=title,
65+
raw_input=data if isinstance(data, dict) else {"data": data},
66+
status="pending",
67+
)
68+
response = await self._bridge.request_permission(
69+
options=[
70+
PermissionOption(option_id="allow", name=submit_label, kind="allow_once"),
71+
PermissionOption(option_id="deny", name=cancel_label, kind="reject_once"),
72+
],
73+
tool_call=tool_call,
74+
)
75+
return isinstance(response.outcome, AllowedOutcome) and response.outcome.option_id == "allow"
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from __future__ import annotations
5+
6+
from typing import Any
7+
8+
import pytest
9+
10+
pytest.importorskip("acp", reason="Optional module [acp_zed] not installed.")
11+
12+
from acp.schema import (
13+
AllowedOutcome,
14+
DeniedOutcome,
15+
RequestPermissionResponse,
16+
)
17+
18+
from beeai_framework.adapters.acp_zed.serve.agent import FsBridge, _active_session
19+
from beeai_framework.adapters.acp_zed.serve.io import ACPZedIOContext
20+
from beeai_framework.utils.io import io_confirm, io_read
21+
22+
23+
class _StubConn:
24+
def __init__(self, *, allow: bool) -> None:
25+
self.allow = allow
26+
self.calls: list[dict[str, Any]] = []
27+
28+
async def request_permission(self, **kwargs: Any) -> RequestPermissionResponse:
29+
self.calls.append(kwargs)
30+
if self.allow:
31+
return RequestPermissionResponse(
32+
outcome=AllowedOutcome(outcome="selected", option_id="allow") # type: ignore[call-arg]
33+
)
34+
return RequestPermissionResponse(outcome=DeniedOutcome(outcome="cancelled"))
35+
36+
37+
@pytest.mark.unit
38+
@pytest.mark.asyncio
39+
async def test_confirm_allow_routes_through_request_permission() -> None:
40+
bridge = FsBridge()
41+
conn = _StubConn(allow=True)
42+
bridge.bind(conn) # type: ignore[arg-type]
43+
44+
tok = _active_session.set("sess-1")
45+
try:
46+
with ACPZedIOContext(bridge):
47+
result = await io_confirm("Are you sure?", data={"tool": "shell"})
48+
finally:
49+
_active_session.reset(tok)
50+
51+
assert result is True
52+
assert len(conn.calls) == 1
53+
call = conn.calls[0]
54+
assert call["session_id"] == "sess-1"
55+
assert [opt.option_id for opt in call["options"]] == ["allow", "deny"]
56+
assert call["tool_call"].raw_input == {"tool": "shell"}
57+
58+
59+
@pytest.mark.unit
60+
@pytest.mark.asyncio
61+
async def test_confirm_deny_returns_false() -> None:
62+
bridge = FsBridge()
63+
bridge.bind(_StubConn(allow=False)) # type: ignore[arg-type]
64+
tok = _active_session.set("sess-1")
65+
try:
66+
with ACPZedIOContext(bridge):
67+
result = await io_confirm("Run this?", data={})
68+
finally:
69+
_active_session.reset(tok)
70+
assert result is False
71+
72+
73+
@pytest.mark.unit
74+
@pytest.mark.asyncio
75+
async def test_read_raises_under_context() -> None:
76+
bridge = FsBridge()
77+
bridge.bind(_StubConn(allow=True)) # type: ignore[arg-type]
78+
tok = _active_session.set("sess-1")
79+
try:
80+
with ACPZedIOContext(bridge), pytest.raises(RuntimeError, match="io_read is not supported"):
81+
await io_read("anything: ")
82+
finally:
83+
_active_session.reset(tok)
84+
85+
86+
@pytest.mark.unit
87+
@pytest.mark.asyncio
88+
async def test_context_restores_previous_handlers() -> None:
89+
"""After exiting the context, the previous (default) handlers are restored."""
90+
bridge = FsBridge()
91+
bridge.bind(_StubConn(allow=True)) # type: ignore[arg-type]
92+
tok = _active_session.set("sess-1")
93+
try:
94+
with ACPZedIOContext(bridge):
95+
assert await io_confirm("x", data={}) is True
96+
# Outside the context, io_read would block on stdin; just confirm it's a
97+
# different function now (the default one, not our _read).
98+
from beeai_framework.utils.io import _storage
99+
100+
restored = _storage.get()
101+
assert restored.read.__name__ != "_read"
102+
finally:
103+
_active_session.reset(tok)

0 commit comments

Comments
 (0)