Skip to content

Commit b22d5b3

Browse files
committed
feat: add tool permissions
1 parent 4199643 commit b22d5b3

File tree

11 files changed

+396
-24
lines changed

11 files changed

+396
-24
lines changed

Makefile

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
.PHONY: chat dev console
1+
.PHONY: dev console install start
22

33
install:
44
uv sync && uv run pre-commit install && cp .env.example .env && echo "Please edit the .env file with your API keys."
55

6-
chat:
7-
uv run chat
8-
96
console:
107
uv run textual console -x SYSTEM -x EVENT -x DEBUG -x INFO
118

129
dev:
1310
uv run textual run --dev -c chat
11+
12+
start:
13+
uv run chat

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ make install
2424
Update the `.env` with your `ANTHROPIC_API_KEY` and then run:
2525

2626
```bash
27-
make chat
27+
make start
2828

2929
# Alternatively, if in dev (see below)
3030
make dev

agent-chat-cli.config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ model: haiku
1010
include_partial_messages: true
1111

1212
# Enable dynamic MCP server inference
13-
mcp_server_inference: true
13+
mcp_server_inference: false
1414

1515
# Named agents with custom configurations
1616
# agents:
@@ -57,4 +57,4 @@ mcp_servers:
5757
disallowed_tools: []
5858

5959
# Permission mode for tool execution
60-
permission_mode: "bypassPermissions"
60+
permission_mode: "default"

src/agent_chat_cli/app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from agent_chat_cli.components.header import Header
88
from agent_chat_cli.components.chat_history import ChatHistory, MessagePosted
99
from agent_chat_cli.components.thinking_indicator import ThinkingIndicator
10+
from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt
1011
from agent_chat_cli.components.user_input import UserInput
1112
from agent_chat_cli.system.agent_loop import AgentLoop
1213
from agent_chat_cli.system.message_bus import MessageBus
@@ -38,12 +39,14 @@ def __init__(self) -> None:
3839
)
3940

4041
self.actions = Actions(self)
42+
self.pending_tool_permission: dict | None = None
4143

4244
def compose(self) -> ComposeResult:
4345
with VerticalScroll():
4446
yield Header()
4547
yield ChatHistory()
4648
yield ThinkingIndicator()
49+
yield ToolPermissionPrompt(actions=self.actions)
4750
yield UserInput(actions=self.actions)
4851

4952
async def on_mount(self) -> None:
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from typing import Any, TYPE_CHECKING
2+
3+
from textual.widget import Widget
4+
from textual.app import ComposeResult
5+
from textual.widgets import Label, Input
6+
from textual.reactive import reactive
7+
8+
from agent_chat_cli.components.caret import Caret
9+
from agent_chat_cli.components.flex import Flex
10+
from agent_chat_cli.components.spacer import Spacer
11+
from agent_chat_cli.utils import get_tool_info
12+
from agent_chat_cli.utils.logger import log_json
13+
14+
if TYPE_CHECKING:
15+
from agent_chat_cli.system.actions import Actions
16+
17+
18+
class ToolPermissionPrompt(Widget):
19+
is_visible = reactive(False)
20+
tool_name = reactive("")
21+
tool_input: dict[str, Any] = reactive({}, init=False) # type: ignore[assignment]
22+
23+
def __init__(self, actions: "Actions") -> None:
24+
super().__init__()
25+
self.actions = actions
26+
27+
def compose(self) -> ComposeResult:
28+
yield Label("", id="tool-display")
29+
yield Label(" [dim]Allow? (Enter=yes, ESC=no, or ask another question):[/dim]")
30+
31+
yield Spacer()
32+
33+
with Flex():
34+
yield Caret()
35+
yield Input(placeholder="Yes", id="permission-input")
36+
37+
def watch_is_visible(self, is_visible: bool) -> None:
38+
self.display = is_visible
39+
40+
if is_visible:
41+
input_widget = self.query_one("#permission-input", Input)
42+
input_widget.value = ""
43+
input_widget.focus()
44+
45+
def watch_tool_name(self, tool_name: str) -> None:
46+
if not tool_name:
47+
return
48+
49+
tool_info = get_tool_info(tool_name)
50+
tool_display_label = self.query_one("#tool-display", Label)
51+
52+
if tool_info["server_name"]:
53+
tool_display = (
54+
rf"[bold]Confirm Tool:[/] [cyan]\[{tool_info['server_name']}][/] "
55+
f"{tool_info['tool_name']}"
56+
)
57+
else:
58+
tool_display = f"[bold]Confirm Tool:[/] {tool_name}"
59+
60+
tool_display_label.update(tool_display)
61+
62+
async def on_input_submitted(self, event: Input.Submitted) -> None:
63+
raw_value = event.value
64+
response = event.value.strip() or "yes"
65+
66+
log_json(
67+
{
68+
"event": "permission_input_submitted",
69+
"raw_value": raw_value,
70+
"stripped_value": event.value.strip(),
71+
"final_response": response,
72+
}
73+
)
74+
75+
await self.actions.respond_to_tool_permission(response)
76+
77+
async def on_input_blurred(self, event: Input.Blurred) -> None:
78+
if self.is_visible:
79+
input_widget = self.query_one("#permission-input", Input)
80+
input_widget.focus()
81+
82+
async def on_key(self, event) -> None:
83+
if event.key == "escape":
84+
log_json({"event": "permission_escape_pressed"})
85+
86+
event.stop()
87+
event.prevent_default()
88+
89+
input_widget = self.query_one("#permission-input", Input)
90+
input_widget.value = "no"
91+
92+
await input_widget.action_submit()

src/agent_chat_cli/docs/architecture.md

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Textual widgets responsible for UI rendering:
1515
- **Message widgets**: SystemMessage, UserMessage, AgentMessage, ToolMessage
1616
- **UserInput**: Handles user text input and submission
1717
- **ThinkingIndicator**: Shows when agent is processing
18+
- **ToolPermissionPrompt**: Interactive widget for approving/denying tool execution requests
1819

1920
### System Layer
2021

@@ -26,6 +27,9 @@ Manages the conversation loop with Claude SDK:
2627
- Emits AgentMessageType events (STREAM_EVENT, ASSISTANT, RESULT)
2728
- Manages session persistence via session_id
2829
- Supports dynamic MCP server inference and loading
30+
- Implements `_can_use_tool` callback for interactive tool permission requests
31+
- Uses `permission_lock` (asyncio.Lock) to serialize parallel permission requests
32+
- Manages `permission_response_queue` for user responses to tool permission prompts
2933

3034
#### MCP Server Inference (`system/mcp_inference.py`)
3135
Intelligently determines which MCP servers are needed for each query:
@@ -42,19 +46,23 @@ Routes agent messages to appropriate UI components:
4246
- Controls thinking indicator state
4347
- Manages scroll-to-bottom behavior
4448
- Displays system messages (e.g., MCP server connection notifications)
49+
- Detects tool permission requests and shows ToolPermissionPrompt
50+
- Manages UI transitions between UserInput and ToolPermissionPrompt
4551

4652
#### Actions (`system/actions.py`)
4753
Centralizes all user-initiated actions and controls:
4854
- **quit()**: Exits the application
4955
- **query(user_input)**: Sends user query to agent loop queue
50-
- **interrupt()**: Stops streaming mid-execution by setting interrupt flag and calling SDK interrupt
56+
- **interrupt()**: Stops streaming mid-execution by setting interrupt flag and calling SDK interrupt (ignores ESC when tool permission prompt is visible)
5157
- **new()**: Starts new conversation by sending NEW_CONVERSATION control command
58+
- **respond_to_tool_permission(response)**: Handles tool permission responses, manages UI state transitions between permission prompt and user input
5259
- Manages UI state (thinking indicator, chat history clearing)
53-
- Directly accesses agent_loop internals (query_queue, client, interrupting flag)
60+
- Directly accesses agent_loop internals (query_queue, client, interrupting flag, permission_response_queue)
5461

5562
Actions are triggered via:
5663
- Keybindings in app.py (ESC → action_interrupt, Ctrl+N → action_new)
5764
- Text commands in user_input.py ("exit", "clear")
65+
- Component events (ToolPermissionPrompt.on_input_submitted → respond_to_tool_permission)
5866

5967
### Utils Layer
6068

@@ -211,6 +219,103 @@ mcp_servers:
211219
# ... rest of config
212220
```
213221

222+
## Tool Permission System
223+
224+
The application implements interactive tool permission requests that allow users to approve or deny tool execution in real-time.
225+
226+
### Components
227+
228+
#### ToolPermissionPrompt (`components/tool_permission_prompt.py`)
229+
Textual widget that displays permission requests to the user:
230+
- Shows tool name with MCP server info
231+
- Provides input field for user response
232+
- Supports Enter (approve), ESC (deny), or custom text responses
233+
234+
### Permission Flow
235+
236+
```
237+
Tool Execution Request (from Claude SDK)
238+
239+
AgentLoop._can_use_tool (callback with permission_lock acquired)
240+
241+
Emit SYSTEM AgentMessage with tool_permission_request data
242+
243+
MessageBus._handle_system detects permission request
244+
245+
Show ToolPermissionPrompt, hide UserInput
246+
247+
User Response:
248+
- Enter (or "yes") → Approve
249+
- ESC (or "no") → Deny
250+
- Custom text → Send to Claude as alternative instruction
251+
252+
Actions.respond_to_tool_permission(response)
253+
254+
Put response on permission_response_queue
255+
256+
Hide ToolPermissionPrompt, show UserInput
257+
258+
AgentLoop._can_use_tool receives response
259+
260+
Return PermissionResultAllow or PermissionResultDeny
261+
262+
Next tool permission request (if multiple tools called)
263+
```
264+
265+
### Serialization with Permission Lock
266+
267+
When multiple tools request permission in parallel, a `permission_lock` (asyncio.Lock) ensures they are handled sequentially:
268+
269+
1. First tool acquires lock → Shows prompt → Waits for response → Releases lock
270+
2. Second tool acquires lock → Shows prompt → Waits for response → Releases lock
271+
3. Third tool acquires lock → Shows prompt → Waits for response → Releases lock
272+
273+
This prevents race conditions where multiple prompts would overwrite each other and ensures each tool gets a dedicated user response.
274+
275+
### Permission Responses
276+
277+
The `_can_use_tool` callback returns typed permission results:
278+
279+
**Approve (CONFIRM)**:
280+
```python
281+
return PermissionResultAllow(
282+
behavior="allow",
283+
updated_input=tool_input,
284+
)
285+
```
286+
287+
**Deny (DENY)**:
288+
```python
289+
return PermissionResultDeny(
290+
behavior="deny",
291+
message="User denied permission",
292+
interrupt=True,
293+
)
294+
```
295+
296+
**Custom Response**:
297+
```python
298+
return PermissionResultDeny(
299+
behavior="deny",
300+
message=user_response, # Alternative instruction sent to Claude
301+
interrupt=True,
302+
)
303+
```
304+
305+
### ESC Key Handling
306+
307+
When ToolPermissionPrompt is visible, the ESC key is intercepted:
308+
- `Actions.interrupt()` checks `permission_prompt.is_visible`
309+
- If visible, returns early without interrupting the agent
310+
- ToolPermissionPrompt's `on_key` handler processes ESC to deny the tool
311+
- If not visible, ESC performs normal interrupt behavior
312+
313+
### System Messages
314+
315+
Permission denials generate system messages in the chat:
316+
- **Denied**: `"Permission denied for {tool_name}"`
317+
- **Custom response**: `"Custom response for {tool_name}: {user_response}"`
318+
214319
## User Commands
215320

216321
### Text Commands
@@ -219,7 +324,7 @@ mcp_servers:
219324

220325
### Keybindings
221326
- **Ctrl+C**: Quit application
222-
- **ESC**: Interrupt streaming response
327+
- **ESC**: Interrupt streaming response (or deny tool permission if prompt visible)
223328
- **Ctrl+N**: Start new conversation
224329

225330
## Session Management
@@ -274,3 +379,5 @@ SDK reconnects to previous session with full history
274379
- Control commands are queued alongside user queries to ensure proper task ordering
275380
- Agent loop processes both strings (user queries) and ControlCommands from the same queue
276381
- Interrupt flag is checked on each streaming message to enable immediate stop
382+
- Tool permission requests are serialized via asyncio.Lock to handle parallel tool calls sequentially
383+
- Permission responses use typed SDK objects (PermissionResultAllow, PermissionResultDeny) rather than plain dictionaries

src/agent_chat_cli/system/actions.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
from textual.widgets import Input
2+
13
from agent_chat_cli.system.agent_loop import AgentLoop
24
from agent_chat_cli.utils.enums import ControlCommand
35
from agent_chat_cli.components.chat_history import ChatHistory
46
from agent_chat_cli.components.thinking_indicator import ThinkingIndicator
7+
from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt
8+
from agent_chat_cli.utils.logger import log_json
59

610

711
class Actions:
@@ -16,6 +20,10 @@ async def query(self, user_input: str) -> None:
1620
await self.agent_loop.query_queue.put(user_input)
1721

1822
async def interrupt(self) -> None:
23+
permission_prompt = self.app.query_one(ToolPermissionPrompt)
24+
if permission_prompt.is_visible:
25+
return
26+
1927
self.agent_loop.interrupting = True
2028
await self.agent_loop.client.interrupt()
2129

@@ -30,3 +38,26 @@ async def new(self) -> None:
3038

3139
thinking_indicator = self.app.query_one(ThinkingIndicator)
3240
thinking_indicator.is_thinking = False
41+
42+
async def respond_to_tool_permission(self, response: str) -> None:
43+
from agent_chat_cli.components.user_input import UserInput
44+
45+
log_json(
46+
{
47+
"event": "permission_response_action",
48+
"response": response,
49+
}
50+
)
51+
52+
await self.agent_loop.permission_response_queue.put(response)
53+
54+
thinking_indicator = self.app.query_one(ThinkingIndicator)
55+
thinking_indicator.is_thinking = True
56+
57+
permission_prompt = self.app.query_one(ToolPermissionPrompt)
58+
permission_prompt.is_visible = False
59+
60+
user_input = self.app.query_one(UserInput)
61+
user_input.display = True
62+
input_widget = user_input.query_one(Input)
63+
input_widget.focus()

0 commit comments

Comments
 (0)