Files
2026-07-13 12:23:54 +08:00

136 lines
7.1 KiB
Python

import asyncio
import tempfile
import unittest
from agent_runtime.context_compactor import ContextCompactor
from agent_runtime.llm import AssistantMessage
from agent_runtime.loop import AgentLoop
from agent_runtime.models import ToolCall, ToolResult
from agent_runtime.prompts import PromptBuilder
from agent_runtime.session_index import SessionIndex
from agent_runtime.tool_executor import ToolExecutor
from agent_runtime.tools import ToolArgumentSchema, ToolRegistry, ToolSpec
class FakeLLM:
def __init__(self, replies):
self.replies = list(replies)
async def complete(self, messages, tools):
return self.replies.pop(0)
class FailingLLM:
async def complete(self, messages, tools):
raise RuntimeError("provider returned invalid response shape")
class AgentLoopTests(unittest.IsolatedAsyncioTestCase):
async def test_no_tool_call_finishes(self):
with tempfile.TemporaryDirectory() as tmp:
index = SessionIndex(tmp)
registry = ToolRegistry([])
loop = AgentLoop(index, PromptBuilder(f"{tmp}/prompts", index, registry), registry, ToolExecutor(registry, index), FakeLLM([AssistantMessage(text="done")]))
events = [event async for event in loop.stream_events("hi")]
self.assertEqual(events[-2]["type"], "done")
turn_id = events[0]["turn_id"]
self.assertTrue(all(event.get("turn_id") == turn_id for event in events))
log_text = (index.logs_dir / "loop_history.jsonl").read_text(encoding="utf-8")
self.assertIn("assistant_finished_without_tools", log_text)
async def test_turn_record_follows_session_created_by_tool(self):
with tempfile.TemporaryDirectory() as tmp:
index = SessionIndex(tmp)
old = index.create(idea="old")
def create_actual(args):
record = index.create(idea="actual")
return ToolResult("create_actual", True, record["session_id"])
registry = ToolRegistry([ToolSpec("create_actual", "Create actual session", create_actual, schema={})])
llm = FakeLLM([AssistantMessage(tool_calls=[ToolCall(name="create_actual", arguments={})]), AssistantMessage(text="finished")])
loop = AgentLoop(index, PromptBuilder(f"{tmp}/prompts", index, registry), registry, ToolExecutor(registry, index), llm)
events = [event async for event in loop.stream_events("start new project")]
active = index.active()
self.assertNotEqual(active["session_id"], old["session_id"])
self.assertEqual(len(index.get(active["session_id"])["recent_turn_records"]), 1)
self.assertEqual(index.get(old["session_id"])["recent_turn_records"], [])
self.assertEqual(events[-1]["session"]["active_session_id"], active["session_id"])
async def test_tool_progress_streams_before_tool_result(self):
with tempfile.TemporaryDirectory() as tmp:
index = SessionIndex(tmp)
release = asyncio.Event()
async def slow_tool(args, runtime):
runtime.emit_progress("started", stage="running")
await release.wait()
return ToolResult("slow_tool", True, "done")
registry = ToolRegistry([ToolSpec("slow_tool", "Slow tool", slow_tool, schema={})])
llm = FakeLLM([AssistantMessage(tool_calls=[ToolCall(name="slow_tool", arguments={})]), AssistantMessage(text="finished")])
loop = AgentLoop(index, PromptBuilder(f"{tmp}/prompts", index, registry), registry, ToolExecutor(registry, index), llm)
agen = loop.stream_events("start")
seen = []
while True:
event = await asyncio.wait_for(anext(agen), timeout=1)
seen.append(event["type"])
if event["type"] == "tool_progress":
self.assertFalse(release.is_set())
break
release.set()
async for event in agen:
seen.append(event["type"])
self.assertLess(seen.index("tool_progress"), seen.index("tool_result"))
async def test_preflight_compact_summarizes_old_history(self):
with tempfile.TemporaryDirectory() as tmp:
index = SessionIndex(tmp)
registry = ToolRegistry([])
compactor = ContextCompactor(None, token_threshold=200, buffer_tokens=0, preserve_last_n=2, summary_max_chars=2000)
loop = AgentLoop(index, PromptBuilder(f"{tmp}/prompts", index, registry), registry, ToolExecutor(registry, index), FakeLLM([AssistantMessage(text="after compact")]), compactor)
loop.history = [
{"role": "user", "content": "old request " + "x" * 1200},
{"role": "assistant", "content": "old answer " + "y" * 1200},
{"role": "user", "content": "recent request"},
{"role": "assistant", "content": "recent answer"},
]
events = [event async for event in loop.stream_events("continue")]
self.assertIn("compact", [event.get("phase") for event in events if event["type"] == "status"])
session = index.active()
self.assertIn("Reference Context Only", session["compacted_summary"])
self.assertGreaterEqual(session["compacted_turns"], 1)
self.assertTrue(session["compaction_snapshots"])
self.assertEqual(loop.history[0]["role"], "system")
self.assertIn("after compact", loop.history[-1]["content"])
self.assertNotIn("old request", index.memory_text())
async def test_llm_sampling_error_yields_error_without_crashing_loop(self):
with tempfile.TemporaryDirectory() as tmp:
index = SessionIndex(tmp)
registry = ToolRegistry([])
loop = AgentLoop(index, PromptBuilder(f"{tmp}/prompts", index, registry), registry, ToolExecutor(registry, index), FailingLLM())
events = [event async for event in loop.stream_events("start")]
self.assertTrue(any(event["type"] == "error" and event.get("metadata", {}).get("error_type") == "llm_sampling_failed" for event in events))
self.assertEqual(events[-2]["type"], "done")
self.assertEqual(events[-1]["type"], "session")
self.assertEqual(index.active()["recent_turn_records"][-1]["status"], "failed")
async def test_tool_call_continues_then_finishes(self):
with tempfile.TemporaryDirectory() as tmp:
index = SessionIndex(tmp)
def hello(args):
return ToolResult("hello", True, "hello result")
registry = ToolRegistry([ToolSpec("hello", "Say hello", hello, schema={"name": ToolArgumentSchema(str, False, "x")})])
llm = FakeLLM([AssistantMessage(tool_calls=[ToolCall(name="hello", arguments={})]), AssistantMessage(text="finished")])
loop = AgentLoop(index, PromptBuilder(f"{tmp}/prompts", index, registry), registry, ToolExecutor(registry, index), llm)
events = [event async for event in loop.stream_events("start")]
self.assertTrue(any(event["type"] == "tool_result" for event in events))
self.assertEqual(events[-2]["assistant"], "finished")