Stabilize live chat tool loop
This commit is contained in:
@@ -113,6 +113,7 @@ def test_stream_chat_forwards_reasoning_toggle_to_thinker(tmp_path, monkeypatch)
|
||||
|
||||
def test_stream_chat_runs_memory_policy_and_reflection_after_completion(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
||||
monkeypatch.setenv("DUCK_ENABLE_REFLECTION", "1")
|
||||
|
||||
async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None):
|
||||
if role == "action":
|
||||
|
||||
@@ -176,6 +176,41 @@ class FakeMalformedActionModelClient:
|
||||
)
|
||||
|
||||
|
||||
class FakeRepeatingActionModelClient:
|
||||
async def chat(self, role, messages):
|
||||
if role == "action":
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content=json.dumps(
|
||||
{
|
||||
"kind": "action_directive",
|
||||
"intent": "repeat same action",
|
||||
"risk_level": "low",
|
||||
"actions": [
|
||||
{
|
||||
"tool": "file_read",
|
||||
"args": {"path": "note.txt"},
|
||||
"reason": "Read requested file",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=5.0,
|
||||
)
|
||||
assert role == "thinker"
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content="Final answer from first observation.",
|
||||
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")
|
||||
@@ -253,6 +288,48 @@ async def test_runtime_rejects_malformed_action_directive_before_tools(tmp_path)
|
||||
assert not any(event.event_type == "tool_call_started" for event in events)
|
||||
|
||||
|
||||
def test_runtime_compacts_large_tool_observations_for_model_context(tmp_path):
|
||||
db_path = str(tmp_path / "duck.sqlite3")
|
||||
task_store = TaskStore(db_path)
|
||||
event_store = EventStore(db_path)
|
||||
loop = RuntimeLoop(task_store, event_store, FakeToolModelClient())
|
||||
|
||||
compact = loop.format_tool_observations_for_model([
|
||||
{
|
||||
"tool": "shell_exec_safe",
|
||||
"result": {
|
||||
"ok": True,
|
||||
"output": "A" * 2500 + "KEEP_TAIL",
|
||||
"metadata": {"command": "ls /tmp"},
|
||||
},
|
||||
}
|
||||
])
|
||||
|
||||
assert "tool_observations" in compact
|
||||
assert "truncated" in compact
|
||||
assert "KEEP_TAIL" in compact
|
||||
assert len(compact) < 2300
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_skips_duplicate_action_within_same_task(tmp_path):
|
||||
(tmp_path / "note.txt").write_text("hello once")
|
||||
db_path = str(tmp_path / "duck.sqlite3")
|
||||
task_store = TaskStore(db_path)
|
||||
event_store = EventStore(db_path)
|
||||
loop = RuntimeLoop(task_store, event_store, FakeRepeatingActionModelClient())
|
||||
|
||||
result = await loop.run_chat("read note.txt", str(tmp_path), debug=True)
|
||||
events = await event_store.list_events(result.task_id)
|
||||
finished_tools = [event for event in events if event.event_type == "tool_call_finished"]
|
||||
skipped_tools = [event for event in events if event.event_type == "tool_call_skipped"]
|
||||
|
||||
assert result.status == "completed"
|
||||
assert len(finished_tools) == 1
|
||||
assert len(skipped_tools) == 1
|
||||
assert skipped_tools[0].payload["reason"] == "duplicate_action"
|
||||
|
||||
|
||||
class FakeApprovalModelClient:
|
||||
async def chat(self, role, messages):
|
||||
if role == "action":
|
||||
|
||||
Reference in New Issue
Block a user