Add approval continuation and multi-step tools
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from fastapi.testclient import TestClient
|
||||
import json
|
||||
import re
|
||||
|
||||
from duck_core.model_client import ModelResponse
|
||||
|
||||
@@ -56,6 +57,16 @@ def test_stream_chat_endpoint_executes_tool_before_streaming_answer(tmp_path, mo
|
||||
|
||||
async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None):
|
||||
assert role == "action"
|
||||
if any("tool_observations" in message["content"] for message in messages):
|
||||
actions = []
|
||||
else:
|
||||
actions = [
|
||||
{
|
||||
"tool": "file_read",
|
||||
"args": {"path": "note.txt"},
|
||||
"reason": "User asked for file contents",
|
||||
}
|
||||
]
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
@@ -64,13 +75,7 @@ def test_stream_chat_endpoint_executes_tool_before_streaming_answer(tmp_path, mo
|
||||
"kind": "action_directive",
|
||||
"intent": "read requested file",
|
||||
"risk_level": "low",
|
||||
"actions": [
|
||||
{
|
||||
"tool": "file_read",
|
||||
"args": {"path": "note.txt"},
|
||||
"reason": "User asked for file contents",
|
||||
}
|
||||
],
|
||||
"actions": actions,
|
||||
}
|
||||
),
|
||||
reasoning_content=None,
|
||||
@@ -101,3 +106,70 @@ def test_stream_chat_endpoint_executes_tool_before_streaming_answer(tmp_path, mo
|
||||
assert "event: content_delta" in body
|
||||
assert "answer from tool" in body
|
||||
assert "event: done" in body
|
||||
|
||||
|
||||
def test_continue_stream_executes_approved_tool_and_streams_answer(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
||||
|
||||
async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None):
|
||||
assert role == "action"
|
||||
if any("tool_observations" in message["content"] for message in messages):
|
||||
actions = []
|
||||
else:
|
||||
actions = [
|
||||
{
|
||||
"tool": "shell_exec_safe",
|
||||
"args": {"command": "uname -a"},
|
||||
"reason": "User asked for system information",
|
||||
}
|
||||
]
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content=json.dumps(
|
||||
{
|
||||
"kind": "action_directive",
|
||||
"intent": "run command",
|
||||
"risk_level": "medium",
|
||||
"actions": actions,
|
||||
}
|
||||
),
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=1.0,
|
||||
)
|
||||
|
||||
async def fake_stream_chat(self, role, messages):
|
||||
assert role == "thinker"
|
||||
observation_message = next(message for message in messages if "tool_observations" in message["content"])
|
||||
assert "uname" in observation_message["content"]
|
||||
yield {"type": "content_delta", "delta": "continued after approval"}
|
||||
|
||||
monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat)
|
||||
monkeypatch.setattr("duck_core.model_client.ModelClient.stream_chat", fake_stream_chat)
|
||||
client = TestClient(create_app())
|
||||
|
||||
with client.stream(
|
||||
"POST",
|
||||
"/v1/chat/stream",
|
||||
json={"message": "run uname", "workspace": str(tmp_path), "debug": True},
|
||||
) as response:
|
||||
initial_body = "".join(response.iter_text())
|
||||
task_id = re.search(r'"task_id"\s*:\s*"([^"]+)"', initial_body).group(1)
|
||||
pending = client.get("/v1/approvals/pending").json()
|
||||
approval = next(item for item in pending if item["task_id"] == task_id)
|
||||
client.post(f"/v1/approvals/{approval['approval_id']}/allow_once")
|
||||
|
||||
with client.stream(
|
||||
"POST",
|
||||
f"/v1/tasks/{approval['task_id']}/continue/stream",
|
||||
json={"approval_id": approval["approval_id"]},
|
||||
) as response:
|
||||
body = "".join(response.iter_text())
|
||||
|
||||
assert "event: tool_approval_requested" in initial_body
|
||||
assert response.status_code == 200
|
||||
assert "event: tool_call_finished" in body
|
||||
assert "event: content_delta" in body
|
||||
assert "continued after approval" in body
|
||||
assert "event: done" in body
|
||||
|
||||
@@ -73,7 +73,7 @@ def test_chat_api_exposes_pending_approval_from_runtime_tool_gate(tmp_path, monk
|
||||
"actions": [
|
||||
{
|
||||
"tool": "shell_exec_safe",
|
||||
"args": {"command": "uname -a"},
|
||||
"args": {"command": "hostname --pending-approval-test"},
|
||||
"reason": "needs shell command",
|
||||
}
|
||||
],
|
||||
@@ -90,7 +90,11 @@ def test_chat_api_exposes_pending_approval_from_runtime_tool_gate(tmp_path, monk
|
||||
|
||||
response = client.post("/v1/chat", json={"message": "run uname", "debug": True})
|
||||
approvals = client.get("/v1/approvals/pending").json()
|
||||
approval = next(
|
||||
item for item in approvals if item["task_id"] == response.json()["task_id"]
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "waiting_for_approval"
|
||||
assert approvals[0]["normalized_action"]["tool"] == "shell_exec_safe"
|
||||
assert approval["normalized_action"]["tool"] == "shell_exec_safe"
|
||||
assert approval["normalized_action"]["args"]["command"] == "hostname --pending-approval-test"
|
||||
|
||||
@@ -12,6 +12,16 @@ from duck_core.tasks.store import TaskStore
|
||||
class FakeToolModelClient:
|
||||
async def chat(self, role, messages):
|
||||
if role == "action":
|
||||
if any("tool_observations" in message["content"] for message in messages):
|
||||
actions = []
|
||||
else:
|
||||
actions = [
|
||||
{
|
||||
"tool": "file_read",
|
||||
"args": {"path": "note.txt"},
|
||||
"reason": "User asked for file contents",
|
||||
}
|
||||
]
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
@@ -20,13 +30,7 @@ class FakeToolModelClient:
|
||||
"kind": "action_directive",
|
||||
"intent": "read requested file",
|
||||
"risk_level": "low",
|
||||
"actions": [
|
||||
{
|
||||
"tool": "file_read",
|
||||
"args": {"path": "note.txt"},
|
||||
"reason": "User asked for file contents",
|
||||
}
|
||||
],
|
||||
"actions": actions,
|
||||
}
|
||||
),
|
||||
reasoning_content=None,
|
||||
@@ -45,6 +49,58 @@ class FakeToolModelClient:
|
||||
)
|
||||
|
||||
|
||||
class FakeMultiStepToolModelClient:
|
||||
async def chat(self, role, messages):
|
||||
if role == "action":
|
||||
observation_text = "\n".join(message["content"] for message in messages)
|
||||
if "tool_observations" not in observation_text:
|
||||
actions = [
|
||||
{
|
||||
"tool": "list_dir",
|
||||
"args": {"path": "."},
|
||||
"reason": "Find available files",
|
||||
}
|
||||
]
|
||||
elif "README.md" in observation_text and "readme contents" not in observation_text:
|
||||
actions = [
|
||||
{
|
||||
"tool": "file_read",
|
||||
"args": {"path": "README.md"},
|
||||
"reason": "Read discovered README",
|
||||
}
|
||||
]
|
||||
else:
|
||||
actions = []
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content=json.dumps(
|
||||
{
|
||||
"kind": "action_directive",
|
||||
"intent": "multi-step file inspection",
|
||||
"risk_level": "low",
|
||||
"actions": actions,
|
||||
}
|
||||
),
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=5.0,
|
||||
)
|
||||
assert role == "thinker"
|
||||
observation_text = "\n".join(message["content"] for message in messages)
|
||||
assert "list_dir" in observation_text
|
||||
assert "file_read" in observation_text
|
||||
assert "readme contents" in observation_text
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content="Readme inspected",
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=12.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_executes_action_directive_tool_and_finishes_with_observation(tmp_path):
|
||||
(tmp_path / "note.txt").write_text("hello from tool")
|
||||
@@ -67,9 +123,38 @@ async def test_runtime_executes_action_directive_tool_and_finishes_with_observat
|
||||
assert tool_finished.payload["result"]["output"] == "hello from tool"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_runs_multiple_tool_steps_before_final_answer(tmp_path):
|
||||
(tmp_path / "README.md").write_text("readme contents")
|
||||
db_path = str(tmp_path / "duck.sqlite3")
|
||||
task_store = TaskStore(db_path)
|
||||
event_store = EventStore(db_path)
|
||||
loop = RuntimeLoop(task_store, event_store, FakeMultiStepToolModelClient())
|
||||
|
||||
result = await loop.run_chat("inspect the workspace readme", str(tmp_path), debug=True)
|
||||
events = await event_store.list_events(result.task_id)
|
||||
finished_tools = [
|
||||
event.payload["tool"] for event in events if event.event_type == "tool_call_finished"
|
||||
]
|
||||
|
||||
assert result.status == "completed"
|
||||
assert result.final_response == "Readme inspected"
|
||||
assert finished_tools == ["list_dir", "file_read"]
|
||||
|
||||
|
||||
class FakeApprovalModelClient:
|
||||
async def chat(self, role, messages):
|
||||
if role == "action":
|
||||
if any("tool_observations" in message["content"] for message in messages):
|
||||
actions = []
|
||||
else:
|
||||
actions = [
|
||||
{
|
||||
"tool": "shell_exec_safe",
|
||||
"args": {"command": "uname -a"},
|
||||
"reason": "User requested system information",
|
||||
}
|
||||
]
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
@@ -78,13 +163,7 @@ class FakeApprovalModelClient:
|
||||
"kind": "action_directive",
|
||||
"intent": "run command",
|
||||
"risk_level": "medium",
|
||||
"actions": [
|
||||
{
|
||||
"tool": "shell_exec_safe",
|
||||
"args": {"command": "uname -a"},
|
||||
"reason": "User requested system information",
|
||||
}
|
||||
],
|
||||
"actions": actions,
|
||||
}
|
||||
),
|
||||
reasoning_content=None,
|
||||
@@ -110,3 +189,197 @@ async def test_runtime_creates_pending_approval_when_tool_requires_it(tmp_path):
|
||||
assert pending[0].task_id == result.task_id
|
||||
assert pending[0].normalized_action["tool"] == "shell_exec_safe"
|
||||
assert any(event.event_type == "tool_approval_requested" for event in events)
|
||||
|
||||
|
||||
class FakeApprovalContinuationModelClient:
|
||||
def __init__(self):
|
||||
self.thinker_messages = []
|
||||
|
||||
async def chat(self, role, messages):
|
||||
if role == "action":
|
||||
if any("tool_observations" in message["content"] for message in messages):
|
||||
actions = []
|
||||
else:
|
||||
actions = [
|
||||
{
|
||||
"tool": "shell_exec_safe",
|
||||
"args": {"command": "uname -a"},
|
||||
"reason": "User requested system information",
|
||||
}
|
||||
]
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content=json.dumps(
|
||||
{
|
||||
"kind": "action_directive",
|
||||
"intent": "run command",
|
||||
"risk_level": "medium",
|
||||
"actions": actions,
|
||||
}
|
||||
),
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=5.0,
|
||||
)
|
||||
assert role == "thinker"
|
||||
self.thinker_messages = messages
|
||||
assert any("tool_observations" in message["content"] for message in messages)
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content="uname completed",
|
||||
reasoning_content="used approved shell command",
|
||||
raw={},
|
||||
latency_ms=10.0,
|
||||
)
|
||||
|
||||
|
||||
class FakeApprovalThenSecondToolModelClient:
|
||||
async def chat(self, role, messages):
|
||||
observation_text = "\n".join(message["content"] for message in messages)
|
||||
if role == "action":
|
||||
if "tool_observations" in observation_text and "second step content" not in observation_text:
|
||||
actions = [
|
||||
{
|
||||
"tool": "file_read",
|
||||
"args": {"path": "second.txt"},
|
||||
"reason": "Read follow-up file after approved command",
|
||||
}
|
||||
]
|
||||
elif "tool_observations" in observation_text:
|
||||
actions = []
|
||||
else:
|
||||
actions = [
|
||||
{
|
||||
"tool": "shell_exec_safe",
|
||||
"args": {"command": "uname -a"},
|
||||
"reason": "User requested system information",
|
||||
}
|
||||
]
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content=json.dumps(
|
||||
{
|
||||
"kind": "action_directive",
|
||||
"intent": "approval then follow-up",
|
||||
"risk_level": "medium",
|
||||
"actions": actions,
|
||||
}
|
||||
),
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=5.0,
|
||||
)
|
||||
assert role == "thinker"
|
||||
assert "shell_exec_safe" in observation_text
|
||||
assert "file_read" in observation_text
|
||||
assert "second step content" in observation_text
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content="approved command and second tool completed",
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=10.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_continues_after_approved_tool_call(tmp_path):
|
||||
db_path = str(tmp_path / "duck.sqlite3")
|
||||
task_store = TaskStore(db_path)
|
||||
event_store = EventStore(db_path)
|
||||
approvals = ApprovalService(db_path)
|
||||
model_client = FakeApprovalContinuationModelClient()
|
||||
loop = RuntimeLoop(task_store, event_store, model_client, approval_service=approvals)
|
||||
|
||||
pending_result = await loop.run_chat("run uname", str(tmp_path), debug=True)
|
||||
pending = await approvals.pending()
|
||||
await approvals.allow_once(pending[0].approval_id)
|
||||
|
||||
result = await loop.continue_after_approval(pending_result.task_id, pending[0].approval_id)
|
||||
events = await event_store.list_events(result.task_id)
|
||||
finished = next(event for event in events if event.event_type == "tool_call_finished")
|
||||
|
||||
assert result.status == "completed"
|
||||
assert result.final_response == "uname completed"
|
||||
assert finished.payload["tool"] == "shell_exec_safe"
|
||||
assert finished.payload["result"]["ok"] is True
|
||||
assert "uname" in finished.payload["result"]["metadata"]["command"]
|
||||
assert any(event.event_type == "task_completed" for event in events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_can_run_followup_tool_after_approval(tmp_path):
|
||||
(tmp_path / "second.txt").write_text("second step content")
|
||||
db_path = str(tmp_path / "duck.sqlite3")
|
||||
task_store = TaskStore(db_path)
|
||||
event_store = EventStore(db_path)
|
||||
approvals = ApprovalService(db_path)
|
||||
loop = RuntimeLoop(
|
||||
task_store,
|
||||
event_store,
|
||||
FakeApprovalThenSecondToolModelClient(),
|
||||
approval_service=approvals,
|
||||
)
|
||||
|
||||
pending_result = await loop.run_chat("run uname then inspect second file", str(tmp_path), debug=True)
|
||||
pending = await approvals.pending()
|
||||
await approvals.allow_once(pending[0].approval_id)
|
||||
|
||||
result = await loop.continue_after_approval(pending_result.task_id, pending[0].approval_id)
|
||||
events = await event_store.list_events(result.task_id)
|
||||
finished_tools = [
|
||||
event.payload["tool"] for event in events if event.event_type == "tool_call_finished"
|
||||
]
|
||||
|
||||
assert result.status == "completed"
|
||||
assert finished_tools == ["shell_exec_safe", "file_read"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_continues_after_denied_tool_call_without_execution(tmp_path):
|
||||
db_path = str(tmp_path / "duck.sqlite3")
|
||||
task_store = TaskStore(db_path)
|
||||
event_store = EventStore(db_path)
|
||||
approvals = ApprovalService(db_path)
|
||||
model_client = FakeApprovalContinuationModelClient()
|
||||
loop = RuntimeLoop(task_store, event_store, model_client, approval_service=approvals)
|
||||
|
||||
pending_result = await loop.run_chat("run uname", str(tmp_path), debug=True)
|
||||
pending = await approvals.pending()
|
||||
await approvals.deny(pending[0].approval_id)
|
||||
|
||||
result = await loop.continue_after_approval(pending_result.task_id, pending[0].approval_id)
|
||||
events = await event_store.list_events(result.task_id)
|
||||
finished = next(event for event in events if event.event_type == "tool_call_finished")
|
||||
|
||||
assert result.status == "completed"
|
||||
assert finished.payload["result"]["ok"] is False
|
||||
assert finished.payload["result"]["metadata"]["decision"] == "deny"
|
||||
assert "denied" in finished.payload["result"]["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_reuses_allow_forever_for_matching_action(tmp_path):
|
||||
db_path = str(tmp_path / "duck.sqlite3")
|
||||
task_store = TaskStore(db_path)
|
||||
event_store = EventStore(db_path)
|
||||
approvals = ApprovalService(db_path)
|
||||
model_client = FakeApprovalContinuationModelClient()
|
||||
loop = RuntimeLoop(task_store, event_store, model_client, approval_service=approvals)
|
||||
|
||||
first_result = await loop.run_chat("run uname", str(tmp_path), debug=True)
|
||||
first_pending = await approvals.pending()
|
||||
await approvals.allow_forever(first_pending[0].approval_id)
|
||||
await loop.continue_after_approval(first_result.task_id, first_pending[0].approval_id)
|
||||
|
||||
second_result = await loop.run_chat("run uname again", str(tmp_path), debug=True)
|
||||
second_events = await event_store.list_events(second_result.task_id)
|
||||
|
||||
assert second_result.status == "completed"
|
||||
assert second_result.final_response == "uname completed"
|
||||
assert not any(event.event_type == "tool_approval_requested" for event in second_events)
|
||||
assert any(event.event_type == "tool_call_finished" for event in second_events)
|
||||
|
||||
@@ -40,3 +40,39 @@ async def test_tool_gateway_runs_allowed_directive(tmp_path):
|
||||
|
||||
assert result.ok is True
|
||||
assert result.metadata["path"].endswith("a.txt")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_gateway_lists_workspace_directory(tmp_path):
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "app.py").write_text("print('duck')")
|
||||
(tmp_path / "README.md").write_text("hello")
|
||||
gateway = ToolGateway.default(str(tmp_path))
|
||||
|
||||
result = await gateway.run_action({"tool": "list_dir", "args": {"path": "."}})
|
||||
escaped = await gateway.run_action({"tool": "list_dir", "args": {"path": ".."}})
|
||||
|
||||
assert result.ok is True
|
||||
assert "README.md" in result.output
|
||||
assert "src/" in result.output
|
||||
assert escaped.ok is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_gateway_searches_file_contents(tmp_path):
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "app.py").write_text("duck tool gateway\\n")
|
||||
(tmp_path / "notes.txt").write_text("other content\\n")
|
||||
gateway = ToolGateway.default(str(tmp_path))
|
||||
|
||||
result = await gateway.run_action(
|
||||
{"tool": "search_files", "args": {"query": "duck tool", "path": "."}}
|
||||
)
|
||||
escaped = await gateway.run_action(
|
||||
{"tool": "search_files", "args": {"query": "duck", "path": ".."}}
|
||||
)
|
||||
|
||||
assert result.ok is True
|
||||
assert "src/app.py:1:duck tool gateway" in result.output
|
||||
assert result.metadata["matches"] == 1
|
||||
assert escaped.ok is False
|
||||
|
||||
Reference in New Issue
Block a user