test_conversation_starters_cache.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700
  1. import asyncio
  2. import dataclasses
  3. import inspect
  4. import json
  5. from collections.abc import AsyncIterator
  6. from types import SimpleNamespace
  7. import pytest
  8. from agents import Tool
  9. from agents.agent_output import AgentOutputSchema, AgentOutputSchemaBase
  10. from agents.handoffs import Handoff as SDKHandoff
  11. from agents.items import HandoffOutputItem, ModelResponse, TResponseInputItem, TResponseStreamEvent
  12. from agents.lifecycle import RunHooksBase
  13. from agents.model_settings import ModelSettings
  14. from agents.models.interface import Model, ModelTracing
  15. from agents.models.openai_responses import OpenAIResponsesModel
  16. from agents.tool import (
  17. ApplyPatchTool,
  18. CodeInterpreterTool,
  19. ComputerTool,
  20. FileSearchTool,
  21. FunctionTool,
  22. HostedMCPTool,
  23. ImageGenerationTool,
  24. LocalShellTool,
  25. ShellTool,
  26. WebSearchTool,
  27. )
  28. from openai import AsyncOpenAI
  29. from openai.types.responses.response_prompt_param import ResponsePromptParam
  30. from pydantic import BaseModel
  31. from agency_swarm import Agency, Agent, GuardrailFunctionOutput, RunContextWrapper, input_guardrail, output_guardrail
  32. from agency_swarm.agent.context_types import AgencyContext, AgentRuntimeState
  33. from agency_swarm.agent.conversation_starters_cache import (
  34. build_run_items_from_cached,
  35. compute_starter_cache_fingerprint,
  36. extract_final_output_text,
  37. extract_starter_segment,
  38. extract_text_from_content,
  39. extract_user_text,
  40. is_simple_text_message,
  41. load_cached_starter,
  42. load_cached_starters,
  43. match_conversation_starter,
  44. merge_cacheable_starters,
  45. parse_cached_output,
  46. prepare_cached_items_for_replay,
  47. reorder_cached_items_for_tools,
  48. save_cached_starter,
  49. )
  50. from agency_swarm.context import MasterContext
  51. from agency_swarm.tools.send_message import Handoff
  52. from agency_swarm.utils.thread import ThreadManager
  53. from tests.deterministic_model import DeterministicModel, _build_message_response, _stream_text_events
  54. @input_guardrail(name="RequireSupportPrefix")
  55. def require_support_prefix(
  56. context: RunContextWrapper, agent: Agent, input_message: str | list[str]
  57. ) -> GuardrailFunctionOutput:
  58. return GuardrailFunctionOutput(output_info="", tripwire_triggered=False)
  59. @output_guardrail(name="BlockEmails")
  60. def block_emails(context: RunContextWrapper, agent: Agent, response_text: str) -> GuardrailFunctionOutput:
  61. return GuardrailFunctionOutput(output_info="", tripwire_triggered=False)
  62. class SystemInstructionsEchoModel(Model):
  63. def __init__(self, model: str = "test-system-instructions") -> None:
  64. self.model = model
  65. async def get_response(
  66. self,
  67. system_instructions: str | None,
  68. input: str | list[TResponseInputItem],
  69. model_settings: ModelSettings,
  70. tools: list[Tool],
  71. output_schema: AgentOutputSchemaBase | None,
  72. handoffs: list[SDKHandoff],
  73. tracing: ModelTracing,
  74. *,
  75. previous_response_id: str | None,
  76. conversation_id: str | None,
  77. prompt: ResponsePromptParam | None,
  78. ) -> ModelResponse:
  79. text = system_instructions or ""
  80. return _build_message_response(text, self.model)
  81. def stream_response(
  82. self,
  83. system_instructions: str | None,
  84. input: str | list[TResponseInputItem],
  85. model_settings: ModelSettings,
  86. tools: list[Tool],
  87. output_schema: AgentOutputSchemaBase | None,
  88. handoffs: list[SDKHandoff],
  89. tracing: ModelTracing,
  90. *,
  91. previous_response_id: str | None,
  92. conversation_id: str | None,
  93. prompt: ResponsePromptParam | None,
  94. ) -> AsyncIterator[TResponseStreamEvent]:
  95. text = system_instructions or ""
  96. return _stream_text_events(text, self.model)
  97. class RecordingHooks(RunHooksBase[MasterContext, Agent]):
  98. def __init__(self) -> None:
  99. self.agent_started = 0
  100. async def on_agent_start(self, context: RunContextWrapper[MasterContext], agent: Agent) -> None:
  101. self.agent_started += 1
  102. def _build_minimal_context(agent: Agent, shared_instructions: str | None) -> AgencyContext:
  103. return AgencyContext(
  104. agency_instance=None,
  105. thread_manager=ThreadManager(),
  106. runtime_state=AgentRuntimeState(agent.tool_concurrency_manager),
  107. shared_instructions=shared_instructions,
  108. )
  109. @pytest.mark.asyncio
  110. async def test_starter_cache_respects_shared_instructions(tmp_path, monkeypatch) -> None:
  111. monkeypatch.setenv("AGENCY_SWARM_CHATS_DIR", str(tmp_path))
  112. starter = "Hello starter"
  113. agent = Agent(
  114. name="CacheAgent",
  115. instructions="Base instructions.",
  116. model=SystemInstructionsEchoModel(),
  117. conversation_starters=[starter],
  118. cache_conversation_starters=True,
  119. )
  120. context_a = _build_minimal_context(agent, "Shared A")
  121. result_a = await agent.get_response(starter, agency_context=context_a)
  122. assert isinstance(result_a.final_output, str)
  123. assert "Shared A" in result_a.final_output
  124. context_b = _build_minimal_context(agent, "Shared B")
  125. result_b = await agent.get_response(starter, agency_context=context_b)
  126. assert isinstance(result_b.final_output, str)
  127. assert "Shared B" in result_b.final_output
  128. @pytest.mark.asyncio
  129. async def test_quick_replies_are_cached_without_conversation_starters(tmp_path, monkeypatch) -> None:
  130. monkeypatch.setenv("AGENCY_SWARM_CHATS_DIR", str(tmp_path))
  131. quick_reply = "hi"
  132. agent = Agent(
  133. name="CacheAgent",
  134. instructions="Base instructions.",
  135. model=SystemInstructionsEchoModel(),
  136. quick_replies=[quick_reply],
  137. cache_conversation_starters=True,
  138. )
  139. context = _build_minimal_context(agent, None)
  140. await agent.get_response(quick_reply, agency_context=context)
  141. cached = load_cached_starter(
  142. agent.name,
  143. quick_reply,
  144. expected_fingerprint=agent._conversation_starters_fingerprint,
  145. )
  146. assert cached is not None
  147. @pytest.mark.asyncio
  148. async def test_quick_replies_cache_and_replay_when_starter_cache_flag_is_disabled(
  149. tmp_path,
  150. monkeypatch,
  151. ) -> None:
  152. monkeypatch.setenv("AGENCY_SWARM_CHATS_DIR", str(tmp_path))
  153. quick_reply = "hi"
  154. model = SystemInstructionsEchoModel()
  155. agent = Agent(
  156. name="CacheAgent",
  157. instructions="Base instructions.",
  158. model=model,
  159. quick_replies=[quick_reply],
  160. cache_conversation_starters=False,
  161. )
  162. first_context = _build_minimal_context(agent, None)
  163. first_result = await agent.get_response(quick_reply, agency_context=first_context)
  164. assert isinstance(first_result.final_output, str)
  165. cached = load_cached_starter(agent.name, quick_reply)
  166. assert cached is not None
  167. expected_output = extract_final_output_text(cached.items)
  168. assert expected_output
  169. async def _fail_get_response(*_args, **_kwargs):
  170. raise RuntimeError("model should not be called for cached quick reply")
  171. monkeypatch.setattr(model, "get_response", _fail_get_response)
  172. second_context = _build_minimal_context(agent, None)
  173. replay_result = await agent.get_response(quick_reply, agency_context=second_context)
  174. assert replay_result.final_output == expected_output
  175. @pytest.mark.asyncio
  176. async def test_quick_replies_stream_replay_when_starter_cache_flag_is_disabled(
  177. tmp_path,
  178. monkeypatch,
  179. ) -> None:
  180. monkeypatch.setenv("AGENCY_SWARM_CHATS_DIR", str(tmp_path))
  181. quick_reply = "hi"
  182. model = SystemInstructionsEchoModel()
  183. agent = Agent(
  184. name="CacheAgent",
  185. instructions="Base instructions.",
  186. model=model,
  187. quick_replies=[quick_reply],
  188. cache_conversation_starters=False,
  189. )
  190. first_context = _build_minimal_context(agent, None)
  191. await agent.get_response(quick_reply, agency_context=first_context)
  192. cached = load_cached_starter(agent.name, quick_reply)
  193. assert cached is not None
  194. expected_output = extract_final_output_text(cached.items)
  195. assert expected_output
  196. def _fail_stream_response(*_args, **_kwargs):
  197. raise RuntimeError("model stream should not be called for cached quick reply")
  198. monkeypatch.setattr(model, "stream_response", _fail_stream_response)
  199. stream_context = _build_minimal_context(agent, None)
  200. stream = agent.get_response_stream(quick_reply, agency_context=stream_context)
  201. async for _event in stream:
  202. pass
  203. assert stream.final_output == expected_output
  204. @pytest.mark.asyncio
  205. async def test_conversation_starter_not_cached_when_starter_cache_flag_is_disabled(
  206. tmp_path,
  207. monkeypatch,
  208. ) -> None:
  209. monkeypatch.setenv("AGENCY_SWARM_CHATS_DIR", str(tmp_path))
  210. starter = "Hello starter"
  211. agent = Agent(
  212. name="CacheAgent",
  213. instructions="Base instructions.",
  214. model=SystemInstructionsEchoModel(),
  215. conversation_starters=[starter],
  216. cache_conversation_starters=False,
  217. )
  218. context = _build_minimal_context(agent, None)
  219. await agent.get_response(starter, agency_context=context)
  220. cached = load_cached_starter(agent.name, starter)
  221. assert cached is None
  222. @pytest.mark.asyncio
  223. async def test_starter_cache_reload_keeps_shared_instructions(tmp_path, monkeypatch) -> None:
  224. monkeypatch.setenv("AGENCY_SWARM_CHATS_DIR", str(tmp_path))
  225. starter = "Hello starter"
  226. shared = "Shared instructions"
  227. agent = Agent(
  228. name="CacheAgent",
  229. instructions="Base instructions.",
  230. model=SystemInstructionsEchoModel(),
  231. conversation_starters=[starter],
  232. cache_conversation_starters=True,
  233. )
  234. context = _build_minimal_context(agent, shared)
  235. await agent.get_response(starter, agency_context=context)
  236. reloaded = Agent(
  237. name="CacheAgent",
  238. instructions="Base instructions.",
  239. model=SystemInstructionsEchoModel(),
  240. conversation_starters=[starter],
  241. cache_conversation_starters=True,
  242. )
  243. reloaded.refresh_conversation_starters_cache(shared_instructions=shared)
  244. cached = load_cached_starter(
  245. reloaded.name,
  246. starter,
  247. expected_fingerprint=reloaded._conversation_starters_fingerprint,
  248. )
  249. assert cached is not None
  250. @pytest.mark.asyncio
  251. async def test_starter_cache_skips_hooks_override(tmp_path, monkeypatch) -> None:
  252. monkeypatch.setenv("AGENCY_SWARM_CHATS_DIR", str(tmp_path))
  253. starter = "Hello starter"
  254. agent = Agent(
  255. name="CacheAgent",
  256. instructions="Base instructions.",
  257. model=SystemInstructionsEchoModel(),
  258. conversation_starters=[starter],
  259. cache_conversation_starters=True,
  260. )
  261. first_context = _build_minimal_context(agent, None)
  262. await agent.get_response(starter, agency_context=first_context)
  263. hooks = RecordingHooks()
  264. second_context = _build_minimal_context(agent, None)
  265. await agent.get_response(starter, agency_context=second_context, hooks_override=hooks)
  266. assert hooks.agent_started >= 1
  267. def test_is_simple_text_message_rejects_invalid_user_item_shapes() -> None:
  268. invalid_cases = [
  269. [
  270. {"role": "system", "content": "You are helpful."},
  271. {"role": "user", "content": "Hello there."},
  272. ],
  273. [
  274. {"role": "user", "content": "Hello."},
  275. {"role": "user", "content": "Follow-up."},
  276. ],
  277. ]
  278. for items in invalid_cases:
  279. assert is_simple_text_message(items) is False
  280. @pytest.mark.asyncio
  281. async def test_warm_conversation_starters_cache_uses_runtime_tools(tmp_path, monkeypatch) -> None:
  282. monkeypatch.setenv("AGENCY_SWARM_CHATS_DIR", str(tmp_path))
  283. starter = "Send a message to Worker: hello"
  284. sender = Agent(
  285. name="Sender",
  286. instructions="Send messages to Worker when asked.",
  287. model=DeterministicModel(default_response="NO_TOOL"),
  288. conversation_starters=[starter],
  289. cache_conversation_starters=False,
  290. )
  291. worker = Agent(
  292. name="Worker",
  293. instructions="A helpful worker.",
  294. model=DeterministicModel(default_response="OK"),
  295. )
  296. agency = Agency(sender, communication_flows=[(sender, worker)])
  297. sender.cache_conversation_starters = True
  298. sender.refresh_conversation_starters_cache(runtime_state=agency.get_agent_runtime_state(sender.name))
  299. await sender.warm_conversation_starters_cache(agency.get_agent_context(sender.name))
  300. cached = load_cached_starter(
  301. sender.name,
  302. starter,
  303. expected_fingerprint=sender._conversation_starters_fingerprint,
  304. )
  305. assert cached is not None
  306. assert any(
  307. isinstance(item, dict) and item.get("type") == "function_call" and item.get("name") == "send_message"
  308. for item in cached.items
  309. )
  310. def test_starter_cache_fingerprint_changes_for_guardrails_runtime_tools_and_handoffs() -> None:
  311. agent_with_guardrails = Agent(
  312. name="GuardrailAgent",
  313. instructions="You are helpful.",
  314. model="gpt-5.4-mini",
  315. input_guardrails=[require_support_prefix],
  316. output_guardrails=[block_emails],
  317. )
  318. agent_without_guardrails = Agent(
  319. name="BaselineAgent",
  320. instructions="You are helpful.",
  321. model="gpt-5.4-mini",
  322. input_guardrails=[],
  323. output_guardrails=[],
  324. )
  325. assert compute_starter_cache_fingerprint(agent_with_guardrails) != compute_starter_cache_fingerprint(
  326. agent_without_guardrails
  327. )
  328. sender = Agent(
  329. name="SenderAgent",
  330. instructions="You are helpful.",
  331. model="gpt-5.4-mini",
  332. )
  333. recipient = Agent(
  334. name="RecipientAgent",
  335. instructions="You are helpful.",
  336. model="gpt-5.4-mini",
  337. )
  338. runtime_state = AgentRuntimeState()
  339. fingerprint_before = compute_starter_cache_fingerprint(sender, runtime_state=runtime_state)
  340. sender.register_subagent(recipient, runtime_state=runtime_state)
  341. fingerprint_after = compute_starter_cache_fingerprint(sender, runtime_state=runtime_state)
  342. assert fingerprint_before != fingerprint_after
  343. handoff_sender = Agent(
  344. name="HandoffSender",
  345. instructions="You are helpful.",
  346. model="gpt-5.4-mini",
  347. )
  348. handoff_recipient = Agent(
  349. name="HandoffRecipient",
  350. instructions="You are helpful.",
  351. model="gpt-5.4-mini",
  352. )
  353. handoff_runtime = AgentRuntimeState()
  354. handoff_before = compute_starter_cache_fingerprint(handoff_sender, runtime_state=handoff_runtime)
  355. handoff_runtime.handoffs.append(Handoff().create_handoff(handoff_recipient))
  356. handoff_after = compute_starter_cache_fingerprint(handoff_sender, runtime_state=handoff_runtime)
  357. assert handoff_before != handoff_after
  358. class _StructuredOutput(BaseModel):
  359. answer: str
  360. def test_cache_helper_text_matching_and_reordering_utilities() -> None:
  361. merged = merge_cacheable_starters(
  362. conversation_starters=["Hi", " hi ", "", "Hello"],
  363. quick_replies=["HI", "hello", "Yo"],
  364. )
  365. assert merged == ["Hi", "Hello", "Yo"]
  366. assert extract_text_from_content("hello") == "hello"
  367. assert extract_text_from_content([{"text": "Hello"}, {"text": " world"}]) == "Hello world"
  368. assert extract_text_from_content([{"type": "input_text"}, 123]) is None
  369. extract_items: list[TResponseInputItem] = [
  370. "skip-me",
  371. {"role": "assistant", "content": "Not user"},
  372. {"role": "user", "content": [{"type": "input_text", "text": "Final user text"}]},
  373. ]
  374. assert extract_user_text(extract_items) == "Final user text"
  375. match_items: list[TResponseInputItem] = [
  376. {"role": "user", "content": "Hi there"},
  377. {"role": "assistant", "type": "message", "content": "Hello"},
  378. ]
  379. segment_items: list[TResponseInputItem] = [
  380. {"role": "user", "content": "Hi there"},
  381. {"role": "assistant", "type": "message", "content": "Hello"},
  382. {"role": "user", "callerAgent": "Worker", "content": "Nested user"},
  383. {"role": "assistant", "type": "message", "content": "Nested reply"},
  384. {"role": "user", "content": "Second question"},
  385. ]
  386. assert match_conversation_starter(match_items, None) is None
  387. assert match_conversation_starter(match_items, ["unknown"]) is None
  388. assert match_conversation_starter(match_items, ["HI THERE"]) == "HI THERE"
  389. segment = extract_starter_segment(segment_items, "hi there")
  390. assert segment is not None
  391. assert segment[0]["content"] == "Hi there"
  392. assert segment[-1]["content"] == "Nested reply"
  393. assert extract_starter_segment(segment_items, "missing") is None
  394. assert extract_starter_segment(segment_items, " ") is None
  395. call_item = {"type": "function_call", "agent": "Primary", "call_id": "call_1", "agent_run_id": "run_1"}
  396. child_item = {"type": "message", "agent": "Worker", "parent_run_id": "call_1"}
  397. unrelated = {"type": "message", "agent": "Primary", "role": "assistant"}
  398. reordered = reorder_cached_items_for_tools([child_item, call_item, unrelated], "Primary")
  399. assert reordered[0] is call_item
  400. assert reordered[1] is child_item
  401. assert reordered[2] is unrelated
  402. assert reorder_cached_items_for_tools([], "Primary") == []
  403. def test_cache_serialization_and_replay_utilities(tmp_path, monkeypatch) -> None:
  404. monkeypatch.setenv("AGENCY_SWARM_CHATS_DIR", str(tmp_path))
  405. starter = "Hello starter"
  406. starter_items = [{"type": "message", "role": "assistant", "content": "cached"}]
  407. saved = save_cached_starter("CacheAgent", starter, starter_items, metadata={"fingerprint": "fp"})
  408. cache_file = next((tmp_path / "starter_cache").glob("*.json"))
  409. assert load_cached_starter("CacheAgent", starter, expected_fingerprint="fp") == saved
  410. assert load_cached_starter("CacheAgent", starter, expected_fingerprint="mismatch") is None
  411. cache_file.write_text("{", encoding="utf-8")
  412. assert load_cached_starter("CacheAgent", starter) is None
  413. cache_file.write_text(json.dumps(["not", "dict"]), encoding="utf-8")
  414. assert load_cached_starter("CacheAgent", starter) is None
  415. cache_file.write_text(json.dumps({"items": "bad"}), encoding="utf-8")
  416. assert load_cached_starter("CacheAgent", starter) is None
  417. cache_file.write_text(
  418. json.dumps({"prompt": 1, "items": starter_items, "metadata": {"source": "chat_history"}}),
  419. encoding="utf-8",
  420. )
  421. assert load_cached_starter("CacheAgent", starter) is None
  422. save_cached_starter("CacheAgent", "hello", starter_items)
  423. loaded = load_cached_starters("CacheAgent", [" ", "", "hello"])
  424. assert list(loaded.keys()) == ["hello"]
  425. replay_source: list[TResponseInputItem] = [
  426. "skip",
  427. {"type": "message", "role": "assistant", "content": "hello"},
  428. {"type": "function_call", "role": "assistant", "agent": "AgentA", "call_id": "old_call", "id": "fc_old"},
  429. {"type": "function_call_output", "call_id": "old_call", "output": "done", "id": "out_old"},
  430. {"type": "function_call", "role": "assistant", "call_id": 123},
  431. {"type": "function_call_output", "call_id": 123, "output": "fallback"},
  432. ]
  433. replayed = prepare_cached_items_for_replay(replay_source, run_trace_id="trace_1", parent_run_id="parent_1")
  434. assert len(replayed) == 5
  435. assert all(item["run_trace_id"] == "trace_1" for item in replayed)
  436. assert all(item["parent_run_id"] == "parent_1" for item in replayed)
  437. assert replayed[0]["id"].startswith("msg_")
  438. assert replayed[1]["id"].startswith("fc_")
  439. assert replayed[1]["call_id"] == replayed[2]["call_id"]
  440. assert "id" not in replayed[2]
  441. assert replayed[3]["call_id"] == replayed[4]["call_id"]
  442. assert replayed[3]["call_id"].startswith("call_")
  443. without_parent = prepare_cached_items_for_replay(replay_source, run_trace_id="trace_2", parent_run_id=None)
  444. assert all("parent_run_id" not in item for item in without_parent)
  445. assert parse_cached_output("plain", None) == "plain"
  446. assert parse_cached_output("plain", str) == "plain"
  447. assert parse_cached_output("plain", AgentOutputSchema(str)) == "plain"
  448. parsed = parse_cached_output('{"answer":"yes"}', _StructuredOutput)
  449. assert isinstance(parsed, _StructuredOutput)
  450. assert parsed.answer == "yes"
  451. parsed_schema = parse_cached_output('{"answer":"yes"}', AgentOutputSchema(_StructuredOutput))
  452. assert isinstance(parsed_schema, _StructuredOutput)
  453. assert parsed_schema.answer == "yes"
  454. build_agent = Agent(name="BuildAgent", instructions="Test", model="gpt-5.4-mini")
  455. build_items: list[TResponseInputItem] = [
  456. "skip",
  457. {"type": "message", "role": "assistant", "content": "Assistant reply"},
  458. {"type": "function_call", "agent": "BuildAgent", "call_id": "call_1", "name": "do_work", "arguments": "{}"},
  459. {"type": "function_call_output", "call_id": "call_1", "output": "done"},
  460. {"type": "reasoning", "id": "bad", "summary": "invalid"},
  461. ]
  462. run_items = build_run_items_from_cached(build_agent, build_items)
  463. assert len(run_items) == 3
  464. assert run_items[0].type == "message_output_item"
  465. assert run_items[1].type == "tool_call_item"
  466. assert run_items[2].type == "tool_call_output_item"
  467. handoff_items = [{"type": "handoff_output_item", "call_id": "call_handoff_1", "output": '{"assistant": "Worker"}'}]
  468. handoff_run_items = build_run_items_from_cached(build_agent, handoff_items)
  469. assert len(handoff_run_items) == 1
  470. assert isinstance(handoff_run_items[0], HandoffOutputItem)
  471. def _make_fingerprint_agent(*, tools: list[object], mcp_config: object, output_type: object = None) -> object:
  472. return SimpleNamespace(
  473. instructions="Base instructions",
  474. prompt=None,
  475. model="gpt-5.4-mini",
  476. model_settings=None,
  477. input_guardrails=[],
  478. output_guardrails=[],
  479. tools=tools,
  480. tool_use_behavior="run_llm_again",
  481. reset_tool_choice=True,
  482. mcp_servers=[],
  483. mcp_config=mcp_config,
  484. handoffs=[],
  485. output_type=output_type,
  486. )
  487. def test_compute_starter_cache_fingerprint_utilities(monkeypatch: pytest.MonkeyPatch) -> None:
  488. tool = FunctionTool(
  489. name="echo",
  490. description="echo",
  491. params_json_schema={"type": "object", "properties": {"message": {"type": "string"}}},
  492. on_invoke_tool=lambda _wrapper, _json: asyncio.sleep(0, result="ok"),
  493. strict_json_schema=True,
  494. )
  495. first = _make_fingerprint_agent(
  496. tools=[tool],
  497. mcp_config={"api_key": "secret-one", "authorization": "Bearer A", "safe": "same"},
  498. )
  499. second = _make_fingerprint_agent(
  500. tools=[tool],
  501. mcp_config={"api_key": "secret-two", "authorization": "Bearer B", "safe": "same"},
  502. )
  503. assert compute_starter_cache_fingerprint(first) == compute_starter_cache_fingerprint(second)
  504. agent_a = _make_fingerprint_agent(tools=[], mcp_config={"region": "eu"})
  505. agent_b = _make_fingerprint_agent(tools=[], mcp_config={"region": "us"})
  506. assert compute_starter_cache_fingerprint(agent_a) != compute_starter_cache_fingerprint(agent_b)
  507. @dataclasses.dataclass
  508. class _Config:
  509. retries: int
  510. secret_token: str
  511. class _CallableWithoutQualname:
  512. __name__ = "callable_no_qualname"
  513. def __call__(self) -> str:
  514. return "ok"
  515. def raising_getsource(_value: object) -> str:
  516. raise OSError("source unavailable")
  517. callable_instructions = _CallableWithoutQualname()
  518. agent = _make_fingerprint_agent(
  519. tools=[],
  520. mcp_config=_Config(retries=3, secret_token="token"),
  521. output_type=AgentOutputSchema(_StructuredOutput),
  522. )
  523. with monkeypatch.context() as ctx:
  524. ctx.setattr(inspect, "getsource", raising_getsource)
  525. fingerprint = compute_starter_cache_fingerprint(
  526. agent,
  527. instructions_override=callable_instructions,
  528. use_instructions_override=True,
  529. shared_instructions=123,
  530. )
  531. assert isinstance(fingerprint, str)
  532. assert len(fingerprint) == 64
  533. class _DummyExecutor:
  534. __name__ = "dummy_executor"
  535. async def __call__(self, *args: object, **kwargs: object) -> dict[str, object]:
  536. return {"stdout": "", "stderr": "", "exit_code": 0}
  537. class _DummyEditor:
  538. async def apply(self, patch: str) -> str: # noqa: ARG002
  539. return "ok"
  540. async def _invoke(_wrapper: object, _arguments_json: str) -> str:
  541. return "ok"
  542. tools = [
  543. FunctionTool(
  544. name="fn_tool",
  545. description="function tool",
  546. params_json_schema={"type": "object", "properties": {"value": {"type": "string"}}},
  547. on_invoke_tool=_invoke,
  548. strict_json_schema=True,
  549. ),
  550. FileSearchTool(vector_store_ids=["vs_1"]),
  551. WebSearchTool(),
  552. HostedMCPTool(
  553. tool_config={
  554. "type": "mcp",
  555. "server_label": "srv",
  556. "server_url": "https://example.com",
  557. "allowed_tools": ["search"],
  558. "authorization": "secret-token",
  559. }
  560. ),
  561. CodeInterpreterTool(tool_config={"type": "code_interpreter"}),
  562. ImageGenerationTool(tool_config={"type": "image_generation"}),
  563. ComputerTool(computer={"environment": "browser"}),
  564. ShellTool(executor=_DummyExecutor()),
  565. LocalShellTool(executor=_DummyExecutor()),
  566. ApplyPatchTool(editor=_DummyEditor()),
  567. object(),
  568. ]
  569. fingerprints = [
  570. compute_starter_cache_fingerprint(_make_fingerprint_agent(tools=[tool], mcp_config={})) for tool in tools
  571. ]
  572. assert all(isinstance(fingerprint, str) and len(fingerprint) == 64 for fingerprint in fingerprints)
  573. def test_compute_starter_cache_fingerprint_changes_when_openclaw_upstream_provider_changes() -> None:
  574. agent = _make_fingerprint_agent(tools=[], mcp_config={})
  575. openai_model = OpenAIResponsesModel(
  576. model="openclaw:main",
  577. openai_client=AsyncOpenAI(base_url="http://127.0.0.1:8000/openclaw/v1", api_key="test-key"),
  578. )
  579. anthropic_model = OpenAIResponsesModel(
  580. model="openclaw:main",
  581. openai_client=AsyncOpenAI(base_url="http://127.0.0.1:8000/openclaw/v1", api_key="test-key"),
  582. )
  583. openai_model._agency_swarm_usage_model_name = "openai/gpt-5.4-mini"
  584. anthropic_model._agency_swarm_usage_model_name = "anthropic/claude-sonnet-4-5"
  585. agent.model = openai_model
  586. openai_fingerprint = compute_starter_cache_fingerprint(agent)
  587. agent.model = anthropic_model
  588. anthropic_fingerprint = compute_starter_cache_fingerprint(agent)
  589. assert openai_fingerprint != anthropic_fingerprint