Skip to content

Commit 5fb9791

Browse files
committed
confirmation request replaced by pre tool hook ask decision
1 parent e490242 commit 5fb9791

File tree

9 files changed

+151
-681
lines changed

9 files changed

+151
-681
lines changed

examples/chat/file_explorer_agent.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
from ragbits.agents import Agent, ToolCallResult
3333
from ragbits.agents._main import AgentRunContext
3434
from ragbits.agents.confirmation import ConfirmationRequest
35-
from ragbits.agents.tool import requires_confirmation
35+
from ragbits.agents.hooks.confirmation import requires_confirmation_hook
3636
from ragbits.chat.interface import ChatInterface
3737
from ragbits.chat.interface.types import (
3838
ChatContext,
@@ -281,7 +281,6 @@ def search_files(pattern: str, directory: str = "") -> str:
281281
return json.dumps(result, indent=2)
282282

283283

284-
@requires_confirmation
285284
def create_file(filepath: str, content: str, context: AgentRunContext | None = None) -> str:
286285
"""
287286
Create a new file with content. Requires confirmation.
@@ -324,7 +323,6 @@ def create_file(filepath: str, content: str, context: AgentRunContext | None = N
324323
return json.dumps(result, indent=2)
325324

326325

327-
@requires_confirmation
328326
def delete_file(filepath: str, context: AgentRunContext | None = None) -> str:
329327
"""
330328
Delete a file. Requires confirmation.
@@ -366,7 +364,6 @@ def delete_file(filepath: str, context: AgentRunContext | None = None) -> str:
366364
return json.dumps(result, indent=2)
367365

368366

369-
@requires_confirmation
370367
def move_file(source: str, destination: str, context: AgentRunContext | None = None) -> str:
371368
"""
372369
Move or rename a file. Requires confirmation.
@@ -424,7 +421,6 @@ def move_file(source: str, destination: str, context: AgentRunContext | None = N
424421
return json.dumps(result, indent=2)
425422

426423

427-
@requires_confirmation
428424
def create_directory(dirpath: str, context: AgentRunContext | None = None) -> str:
429425
"""
430426
Create a new directory. Requires confirmation.
@@ -459,7 +455,6 @@ def create_directory(dirpath: str, context: AgentRunContext | None = None) -> st
459455
return json.dumps(result, indent=2)
460456

461457

462-
@requires_confirmation
463458
def delete_directory(dirpath: str, context: AgentRunContext | None = None) -> str:
464459
"""
465460
Delete an empty directory. Requires confirmation.
@@ -545,6 +540,13 @@ def __init__(self) -> None:
545540
delete_directory,
546541
]
547542

543+
# Create confirmation hook for destructive tools
544+
self.hooks = [
545+
requires_confirmation_hook(
546+
tools=["create_file", "delete_file", "move_file", "create_directory", "delete_directory"]
547+
)
548+
]
549+
548550
async def chat( # noqa: PLR0912
549551
self,
550552
message: str,
@@ -554,10 +556,10 @@ async def chat( # noqa: PLR0912
554556
"""
555557
Chat implementation with non-blocking confirmation support.
556558
557-
The agent will check context.confirmed_tools for any confirmations.
558-
If a tool needs confirmation but hasn't been confirmed yet, it will
559+
The agent will check context.confirmed_hooks for any confirmations.
560+
If a hook needs confirmation but hasn't been confirmed yet, it will
559561
yield a ConfirmationRequest and exit. The frontend will then send a
560-
new request with the confirmation in context.confirmed_tools.
562+
new request with the confirmation in context.confirmed_tools (mapped to confirmed_hooks).
561563
"""
562564
# Create agent with history passed explicitly
563565
agent: Agent = Agent(
@@ -578,15 +580,17 @@ async def chat( # noqa: PLR0912
578580
Restricted to: {TEMP_DIR}
579581
""",
580582
tools=self.tools, # type: ignore[arg-type]
583+
hooks=self.hooks,
581584
history=history,
582585
)
583586

584-
# Create agent context with confirmed_tools from the request context
587+
# Create agent context with confirmed_hooks from the request context
585588
agent_context: AgentRunContext = AgentRunContext()
586589

587-
# Pass confirmed_tools from ChatContext to AgentRunContext
590+
# Pass confirmed_tools from ChatContext to AgentRunContext.confirmed_hooks
591+
# (Frontend still uses confirmed_tools but we map it to confirmed_hooks)
588592
if context.confirmed_tools:
589-
agent_context.confirmed_tools = context.confirmed_tools
593+
agent_context.confirmed_hooks = context.confirmed_tools
590594

591595
# Run agent in streaming mode with the message and history
592596
async for response in agent.run_streaming(

packages/ragbits-agents/src/ragbits/agents/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
HookManager,
1616
)
1717
from ragbits.agents.post_processors.base import PostProcessor, StreamingPostProcessor
18-
from ragbits.agents.tool import requires_confirmation
1918
from ragbits.agents.tools import LongTermMemory, MemoryEntry, create_memory_tools
2019
from ragbits.agents.types import QuestionAnswerAgent, QuestionAnswerPromptInput, QuestionAnswerPromptOutput
2120

@@ -40,5 +39,4 @@
4039
"ToolCall",
4140
"ToolCallResult",
4241
"create_memory_tools",
43-
"requires_confirmation",
4442
]

packages/ragbits-agents/src/ragbits/agents/_main.py

Lines changed: 21 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import asyncio
2-
import hashlib
3-
import json
42
import types
53
import uuid
64
from collections.abc import AsyncGenerator, AsyncIterator, Callable
@@ -216,9 +214,9 @@ class AgentRunContext(BaseModel, Generic[DepsT]):
216214
"""Whether to stream events from downstream agents when tools execute other agents."""
217215
downstream_agents: dict[str, "Agent"] = Field(default_factory=dict)
218216
"""Registry of all agents that participated in this run"""
219-
confirmed_tools: list[dict[str, Any]] | None = Field(
217+
confirmed_hooks: list[dict[str, Any]] | None = Field(
220218
default=None,
221-
description="List of confirmed/declined tools from the frontend",
219+
description="List of confirmed/declined hooks. Each entry has 'confirmation_id' and 'confirmed' (bool)",
222220
)
223221

224222
def register_agent(self, agent: "Agent") -> None:
@@ -968,6 +966,7 @@ async def _execute_tool(
968966
# Execute PRE_TOOL hooks with chaining
969967
pre_tool_result = await self.hook_manager.execute_pre_tool(
970968
tool_call=tool_call,
969+
context=context,
971970
)
972971

973972
# Check decision
@@ -979,81 +978,28 @@ async def _execute_tool(
979978
result=pre_tool_result.reason or "Tool execution denied",
980979
)
981980
return
982-
# TODO: possible place/way of replacing confirmation by handling ask decision
983-
# elif pre_tool_result.decision == "ask":
984-
# confirmation_id = hashlib.sha256(
985-
# f"{tool_call.name}:{json.dumps(tool_call.arguments, sort_keys=True)}".encode()
986-
# ).hexdigest()[:CONFIRMATION_ID_LENGTH]
987-
#
988-
# request = ConfirmationRequest(
989-
# confirmation_id=confirmation_id,
990-
# tool_name=tool_call.name,
991-
# tool_description=tool.description or "",
992-
# arguments=tool_call.arguments,
993-
# )
994-
#
995-
# # Yield confirmation request (will be streamed to frontend)
996-
# yield request
997-
#
998-
# yield ToolCallResult(
999-
# id=tool_call.id,
1000-
# name=tool_call.name,
1001-
# arguments=tool_call.arguments,
1002-
# result=pre_tool_result.reason or "Tool requires user confirmation",
1003-
# )
1004-
# return
1005-
1006-
# Always update arguments (chained from hooks)
1007-
tool_call.arguments = pre_tool_result.arguments
1008-
1009-
# Check if tool requires confirmation
1010-
if tool.requires_confirmation:
1011-
# Check if this tool has been confirmed in the context
1012-
confirmed_tools = context.confirmed_tools or []
1013-
1014-
# Generate a stable confirmation ID based on tool name and arguments
1015-
confirmation_id = hashlib.sha256(
1016-
f"{tool_call.name}:{json.dumps(tool_call.arguments, sort_keys=True)}".encode()
1017-
).hexdigest()[:CONFIRMATION_ID_LENGTH]
1018-
1019-
# Check if this specific tool call has been confirmed or declined
1020-
is_confirmed = any(
1021-
ct.get("confirmation_id") == confirmation_id and ct.get("confirmed") for ct in confirmed_tools
1022-
)
1023-
is_declined = any(
1024-
ct.get("confirmation_id") == confirmation_id and not ct.get("confirmed", True) for ct in confirmed_tools
981+
# Handle "ask" decision from hooks
982+
elif pre_tool_result.decision == "ask":
983+
request = ConfirmationRequest(
984+
confirmation_id=pre_tool_result.confirmation_id or "",
985+
tool_name=tool_call.name,
986+
tool_description=pre_tool_result.reason or "Hook requires user confirmation",
987+
arguments=pre_tool_result.arguments,
1025988
)
1026989

1027-
if is_declined:
1028-
# Tool was explicitly declined - skip execution entirely
1029-
yield ToolCallResult(
1030-
id=tool_call.id,
1031-
name=tool_call.name,
1032-
arguments=tool_call.arguments,
1033-
result="❌ Action declined by user",
1034-
)
1035-
return
1036-
1037-
if not is_confirmed:
1038-
# Tool not confirmed yet - create and yield confirmation request
1039-
request = ConfirmationRequest(
1040-
confirmation_id=confirmation_id,
1041-
tool_name=tool_call.name,
1042-
tool_description=tool.description or "",
1043-
arguments=tool_call.arguments,
1044-
)
990+
# Yield confirmation request (will be streamed to frontend)
991+
yield request
1045992

1046-
# Yield confirmation request (will be streamed to frontend)
1047-
yield request
993+
yield ToolCallResult(
994+
id=tool_call.id,
995+
name=tool_call.name,
996+
arguments=tool_call.arguments,
997+
result=pre_tool_result.reason or "Hook requires user confirmation",
998+
)
999+
return
10481000

1049-
# Yield a pending result and exit without executing
1050-
yield ToolCallResult(
1051-
id=tool_call.id,
1052-
name=tool_call.name,
1053-
arguments=tool_call.arguments,
1054-
result="⏳ Awaiting user confirmation",
1055-
)
1056-
return
1001+
# Always update arguments (chained from hooks)
1002+
tool_call.arguments = pre_tool_result.arguments
10571003

10581004
tool_error: Exception | None = None
10591005

packages/ragbits-agents/src/ragbits/agents/hooks/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ async def validate_input(input_data: PreToolInput) -> PreToolOutput | None:
4545
"""
4646

4747
from ragbits.agents.hooks.base import Hook
48+
from ragbits.agents.hooks.confirmation import requires_confirmation_hook
4849
from ragbits.agents.hooks.manager import HookManager
4950
from ragbits.agents.hooks.types import (
5051
EventType,
@@ -65,4 +66,6 @@ async def validate_input(input_data: PreToolInput) -> PreToolOutput | None:
6566
"PostToolOutput",
6667
"PreToolInput",
6768
"PreToolOutput",
69+
# Helper functions
70+
"requires_confirmation_hook",
6871
]
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""
2+
Helper functions for creating common hooks.
3+
4+
This module provides factory functions for creating commonly used hooks.
5+
"""
6+
7+
from ragbits.agents.hooks.base import Hook
8+
from ragbits.agents.hooks.types import EventType, PreToolInput, PreToolOutput
9+
10+
11+
def requires_confirmation_hook(tools: list[str] | None = None, priority: int = 1) -> Hook:
12+
"""
13+
Create a hook that requires user confirmation before tool execution.
14+
15+
This replaces the @requires_confirmation decorator with a hook-based approach.
16+
The hook returns "ask" decision, which causes the agent to yield a ConfirmationRequest
17+
and wait for user approval/decline.
18+
19+
Args:
20+
tools: List of tool names to require confirmation for. If None, applies to all tools.
21+
priority: Hook priority (default: 1, runs first)
22+
23+
Returns:
24+
Hook configured to require confirmation
25+
26+
Example:
27+
```python
28+
from ragbits.agents import Agent
29+
from ragbits.agents.hooks.helpers import requires_confirmation_hook
30+
31+
agent = Agent(tools=[delete_file, send_email],
32+
hooks=[requires_confirmation_hook(tools=["delete_file", "send_email"])])
33+
```
34+
"""
35+
36+
async def confirm_hook(input_data: PreToolInput) -> PreToolOutput:
37+
"""Hook that always returns 'ask' to require confirmation."""
38+
return PreToolOutput(
39+
arguments=input_data.tool_call.arguments,
40+
decision="ask",
41+
reason=f"Tool '{input_data.tool_call.name}' requires user confirmation",
42+
)
43+
44+
return Hook(
45+
event_type=EventType.PRE_TOOL,
46+
callback=confirm_hook, # type: ignore[arg-type]
47+
tools=tools,
48+
priority=priority,
49+
)

0 commit comments

Comments
 (0)