main
py 1,285 lines 42.4 KB
Raw
1 from __future__ import annotations
2
3 import asyncio
4 import importlib
5 import sys
6 import types
7 from dataclasses import dataclass
8 from pathlib import Path
9
10
11 @dataclass
12 class _FakeResponse:
13 message: str
14 break_loop: bool
15 additional: dict | None = None
16
17
18 class _FakeTool:
19 def __init__(
20 self,
21 agent,
22 name: str,
23 method: str | None,
24 args: dict | None,
25 message: str,
26 loop_data=None,
27 **kwargs,
28 ) -> None:
29 self.agent = agent
30 self.name = name
31 self.method = method
32 self.args = args or {}
33 self.message = message
34 self.loop_data = loop_data
35
36
37 class _FakeContext:
38 def __init__(self, data: dict | None = None) -> None:
39 self.id = "ctx"
40 self.data = data or {}
41
42 def get_data(self, key, recursive=True):
43 return self.data.get(key)
44
45 def set_data(self, key, value, recursive=True):
46 self.data[key] = value
47
48
49 class _FakeAgent:
50 def __init__(self) -> None:
51 self.data = {}
52 self.context = _FakeContext()
53 self.history = types.SimpleNamespace(output=lambda: [])
54
55 def read_prompt(self, _name: str, **kwargs) -> str:
56 return f"deleted {kwargs.get('memory_count', 0)}"
57
58
59 @dataclass
60 class _FakeSkill:
61 name: str
62 description: str
63 path: Path
64 version: str = ""
65 tags: list[str] | None = None
66
67
68 def _normalize_loaded_skill_names(raw) -> list[str]:
69 if not isinstance(raw, list):
70 return []
71 return [name for value in raw if (name := str(value or "").strip())]
72
73
74 def _get_loaded_skill_names(agent) -> list[str]:
75 names = _normalize_loaded_skill_names(agent.context.get_data("loaded_skills"))
76 if names:
77 return names
78 names = _normalize_loaded_skill_names(agent.data.get("loaded_skills"))
79 if names:
80 _set_loaded_skill_names(agent, names)
81 return names
82
83
84 def _set_loaded_skill_names(agent, names) -> list[str]:
85 names = _normalize_loaded_skill_names(names)
86 agent.context.set_data("loaded_skills", names or None)
87 return names
88
89
90 def _add_loaded_skill_name(agent, skill_name, *, limit=None) -> list[str]:
91 skill_name = str(skill_name or "").strip()
92 names = [name for name in _get_loaded_skill_names(agent) if name != skill_name]
93 if skill_name:
94 names.append(skill_name)
95 return _set_loaded_skill_names(agent, names[-(limit or 20):])
96
97
98 def _skill_instruction_name(message) -> str:
99 match message:
100 case {
101 "content": {
102 "skill_instructions": {
103 "content_included": included,
104 "name": name,
105 }
106 }
107 } if included:
108 return str(name or "").strip()
109 return ""
110
111
112 def _install_tool_stub(monkeypatch) -> None:
113 tool_stub = types.ModuleType("helpers.tool")
114 tool_stub.Tool = _FakeTool
115 tool_stub.Response = _FakeResponse
116 monkeypatch.setitem(sys.modules, "helpers.tool", tool_stub)
117
118
119 def _load_skills_tool(monkeypatch, skill_root: Path):
120 _install_tool_stub(monkeypatch)
121
122 skills_stub = types.ModuleType("helpers.skills")
123 skills_stub.AGENT_DATA_NAME_LOADED_SKILLS = "loaded_skills"
124 skills_stub.CONTEXT_DATA_NAME_LOADED_SKILLS = "loaded_skills"
125 skills_stub.MAX_ACTIVE_SKILLS = 20
126 fake_skill = _FakeSkill(
127 name="browser-form-workflows",
128 description="Use for complex browser forms.",
129 path=skill_root,
130 tags=[],
131 )
132 skills_stub.list_skills = lambda *args, **kwargs: [fake_skill]
133 skills_stub.search_skills = lambda *args, **kwargs: [fake_skill]
134 skills_stub.find_skill = lambda *args, **kwargs: fake_skill
135 skills_stub.load_skill_for_agent = (
136 lambda *args, **kwargs: "Skill: browser-form-workflows\n\nInstructions:\nUse labels before typing."
137 )
138 skills_stub.add_loaded_skill_name = _add_loaded_skill_name
139 skills_stub.get_loaded_skill_names = _get_loaded_skill_names
140 skills_stub.set_loaded_skill_names = _set_loaded_skill_names
141 skills_stub.skill_instruction_name = _skill_instruction_name
142 monkeypatch.setitem(sys.modules, "helpers.skills", skills_stub)
143 import helpers
144 monkeypatch.setattr(helpers, "skills", skills_stub, raising=False)
145
146 print_style_stub = types.ModuleType("helpers.print_style")
147 print_style_stub.PrintStyle = lambda *args, **kwargs: types.SimpleNamespace(
148 print=lambda *a, **k: None
149 )
150 monkeypatch.setitem(sys.modules, "helpers.print_style", print_style_stub)
151
152 sys.modules.pop("tools.skills_tool", None)
153 return importlib.import_module("tools.skills_tool")
154
155
156 class _FakeExtension:
157 def __init__(self, agent=None):
158 self.agent = agent
159
160
161 class _FakeLoadedSkillAgent:
162 def __init__(self) -> None:
163 self.data = {}
164 self.context = _FakeContext({"loaded_skills": ["browser-form-workflows"]})
165 self.added_tool_results = []
166
167 def hist_add_tool_result(self, tool_name: str, tool_result: str, **kwargs):
168 content = {"tool_name": tool_name, "tool_result": tool_result, **kwargs}
169 self.added_tool_results.append(content)
170 return types.SimpleNamespace(
171 output=lambda: [{"ai": False, "content": content}]
172 )
173
174
175 def _load_loaded_skills_extension(monkeypatch, skill_root: Path):
176 extension_stub = types.ModuleType("helpers.extension")
177 extension_stub.Extension = _FakeExtension
178 monkeypatch.setitem(sys.modules, "helpers.extension", extension_stub)
179
180 agent_stub = types.ModuleType("agent")
181 agent_stub.LoopData = lambda **kwargs: types.SimpleNamespace(**kwargs)
182 monkeypatch.setitem(sys.modules, "agent", agent_stub)
183
184 skills_stub = types.ModuleType("helpers.skills")
185 fake_skill = _FakeSkill(
186 name="browser-form-workflows",
187 description="Use for complex browser forms.",
188 path=skill_root,
189 tags=[],
190 )
191 skills_stub.find_skill = lambda *args, **kwargs: fake_skill
192 skills_stub.load_skill_for_agent = (
193 lambda *args, **kwargs: "Skill: browser-form-workflows\n\nInstructions:\nUse labels before typing."
194 )
195 skills_stub.get_loaded_skill_names = _get_loaded_skill_names
196 skills_stub.set_loaded_skill_names = _set_loaded_skill_names
197 skills_stub.skill_instruction_name = _skill_instruction_name
198 monkeypatch.setitem(sys.modules, "helpers.skills", skills_stub)
199
200 tokens_stub = types.ModuleType("helpers.tokens")
201 tokens_stub.approximate_tokens = lambda text: len(str(text).split())
202 monkeypatch.setitem(sys.modules, "helpers.tokens", tokens_stub)
203
204 import helpers
205
206 monkeypatch.setattr(helpers, "skills", skills_stub, raising=False)
207 monkeypatch.setattr(helpers, "tokens", tokens_stub, raising=False)
208
209 skills_tool_stub = types.ModuleType("tools.skills_tool")
210 skills_tool_stub.DATA_NAME_LOADED_SKILLS = "loaded_skills"
211 monkeypatch.setitem(sys.modules, "tools.skills_tool", skills_tool_stub)
212
213 module_name = "extensions.python.message_loop_prompts_after._65_include_loaded_skills"
214 sys.modules.pop(module_name, None)
215 return importlib.import_module(module_name)
216
217
218 def _load_relevant_skills_extension(
219 monkeypatch, queries: list[str], *, skills_tool_allowed: bool = True
220 ):
221 extension_stub = types.ModuleType("helpers.extension")
222 extension_stub.Extension = _FakeExtension
223 monkeypatch.setitem(sys.modules, "helpers.extension", extension_stub)
224
225 agent_stub = types.ModuleType("agent")
226 agent_stub.LoopData = lambda **kwargs: types.SimpleNamespace(**kwargs)
227 monkeypatch.setitem(sys.modules, "agent", agent_stub)
228
229 skills_stub = types.ModuleType("helpers.skills")
230
231 def _search_skills(query, *args, **kwargs):
232 queries.append(query)
233 return []
234
235 skills_stub.search_skills = _search_skills
236 monkeypatch.setitem(sys.modules, "helpers.skills", skills_stub)
237
238 tool_policy_stub = types.ModuleType("helpers.tool_policy")
239 tool_policy_stub.resolve_tool = lambda *args, **kwargs: types.SimpleNamespace(
240 allowed=skills_tool_allowed
241 )
242 monkeypatch.setitem(sys.modules, "helpers.tool_policy", tool_policy_stub)
243
244 import helpers
245
246 monkeypatch.setattr(helpers, "skills", skills_stub, raising=False)
247 monkeypatch.setattr(helpers, "tool_policy", tool_policy_stub, raising=False)
248
249 module_name = "extensions.python.message_loop_prompts_after._63_recall_relevant_skills"
250 sys.modules.pop(module_name, None)
251 return importlib.import_module(module_name)
252
253
254 def _load_computer_use_remote_tool(monkeypatch):
255 _install_tool_stub(monkeypatch)
256
257 history_stub = types.ModuleType("helpers.history")
258
259 class _RawMessage(dict):
260 def __init__(self, raw_content, preview):
261 super().__init__(raw_content=raw_content, preview=preview)
262
263 history_stub.RawMessage = _RawMessage
264 monkeypatch.setitem(sys.modules, "helpers.history", history_stub)
265
266 print_style_stub = types.ModuleType("helpers.print_style")
267 print_style_stub.PrintStyle = lambda *args, **kwargs: types.SimpleNamespace(
268 print=lambda *a, **k: None
269 )
270 monkeypatch.setitem(sys.modules, "helpers.print_style", print_style_stub)
271
272 ws_stub = types.ModuleType("helpers.ws")
273 ws_stub.NAMESPACE = "/test"
274 monkeypatch.setitem(sys.modules, "helpers.ws", ws_stub)
275
276 ws_manager_stub = types.ModuleType("helpers.ws_manager")
277 ws_manager_stub.ConnectionNotFoundError = RuntimeError
278 ws_manager_stub.get_shared_ws_manager = lambda: types.SimpleNamespace(
279 emit_to=lambda *a, **k: None
280 )
281 monkeypatch.setitem(sys.modules, "helpers.ws_manager", ws_manager_stub)
282
283 ws_runtime_stub = types.ModuleType("plugins._a0_connector.helpers.ws_runtime")
284 ws_runtime_stub.clear_pending_computer_use_op = lambda *args, **kwargs: None
285 ws_runtime_stub.computer_use_metadata_for_sid = lambda *args, **kwargs: {}
286 ws_runtime_stub.select_computer_use_target_sid = lambda *args, **kwargs: "sid"
287 ws_runtime_stub.store_pending_computer_use_op = lambda *args, **kwargs: None
288 monkeypatch.setitem(
289 sys.modules,
290 "plugins._a0_connector.helpers.ws_runtime",
291 ws_runtime_stub,
292 )
293
294 sys.modules.pop("plugins._a0_connector.tools.computer_use_remote", None)
295 return importlib.import_module("plugins._a0_connector.tools.computer_use_remote")
296
297
298 def test_skills_tool_accepts_action_alias_for_search(monkeypatch, tmp_path: Path):
299 module = _load_skills_tool(monkeypatch, tmp_path)
300 tool = module.SkillsTool(
301 _FakeAgent(),
302 "skills_tool",
303 None,
304 {"action": "search", "query": "browser forms"},
305 "",
306 None,
307 )
308
309 response = asyncio.run(tool.execute(**tool.args))
310
311 assert "browser-form-workflows" in response.message
312
313
314 def test_skills_tool_accepts_method_as_deprecated_action_alias(
315 monkeypatch, tmp_path: Path
316 ):
317 module = _load_skills_tool(monkeypatch, tmp_path)
318 tool = module.SkillsTool(
319 _FakeAgent(),
320 "skills_tool",
321 None,
322 {"method": "search", "query": "browser forms"},
323 "",
324 None,
325 )
326
327 response = asyncio.run(tool.execute(**tool.args))
328
329 assert "browser-form-workflows" in response.message
330 assert tool.args["action"] == "search"
331
332
333 def test_skills_tool_defaults_missing_action_to_list(monkeypatch, tmp_path: Path):
334 module = _load_skills_tool(monkeypatch, tmp_path)
335 tool = module.SkillsTool(
336 _FakeAgent(),
337 "skills_tool",
338 None,
339 {},
340 "",
341 None,
342 )
343
344 response = asyncio.run(tool.execute())
345
346 assert "Available skills" in response.message
347 assert "browser-form-workflows" in response.message
348 assert "slash commands" not in response.message
349
350
351 def test_skills_tool_load_appends_skill_instructions_as_tool_result(
352 monkeypatch, tmp_path: Path
353 ):
354 module = _load_skills_tool(monkeypatch, tmp_path)
355 agent = _FakeAgent()
356 tool = module.SkillsTool(
357 agent,
358 "skills_tool",
359 None,
360 {"action": "load", "skill_name": "browser-form-workflows"},
361 "",
362 None,
363 )
364
365 response = asyncio.run(tool.execute(**tool.args))
366
367 assert "Skill: browser-form-workflows" in response.message
368 assert response.additional["skill_instructions"]["name"] == "browser-form-workflows"
369 assert response.additional["skill_instructions"]["content_included"] is True
370 assert agent.context.get_data("loaded_skills") == ["browser-form-workflows"]
371
372
373 def test_skills_tool_load_omits_duplicate_visible_skill(
374 monkeypatch, tmp_path: Path
375 ):
376 module = _load_skills_tool(monkeypatch, tmp_path)
377 agent = _FakeAgent()
378 tool = module.SkillsTool(
379 agent,
380 "skills_tool",
381 None,
382 {"action": "load", "skill_name": "browser-form-workflows"},
383 "",
384 None,
385 )
386 first = asyncio.run(tool.execute(**tool.args))
387 loaded_message = {
388 "ai": False,
389 "content": {"skill_instructions": first.additional["skill_instructions"]},
390 }
391 agent.history = types.SimpleNamespace(output=lambda: [loaded_message])
392
393 second = asyncio.run(tool.execute(**tool.args))
394
395 assert "already loaded in visible chat history" in second.message
396 assert "Instructions:\nUse labels before typing." not in second.message
397 assert second.additional["skill_instructions"]["content_included"] is False
398 assert second.additional["skill_instructions"]["already_loaded"] is True
399 assert agent.context.get_data("loaded_skills") == ["browser-form-workflows"]
400
401
402 def test_skills_tool_load_reloads_when_prior_skill_is_not_model_visible(
403 monkeypatch, tmp_path: Path
404 ):
405 module = _load_skills_tool(monkeypatch, tmp_path)
406 agent = _FakeAgent()
407 tool = module.SkillsTool(
408 agent,
409 "skills_tool",
410 None,
411 {"action": "load", "skill_name": "browser-form-workflows"},
412 "",
413 None,
414 )
415 first = asyncio.run(tool.execute(**tool.args))
416 hidden_message = types.SimpleNamespace(
417 summary="",
418 content={"skill_instructions": first.additional["skill_instructions"]},
419 )
420 agent.history = types.SimpleNamespace(
421 all_messages=lambda: [hidden_message],
422 output=lambda: [
423 {
424 "ai": False,
425 "content": "Earlier history was summarized and no skill body is visible.",
426 }
427 ],
428 )
429
430 second = asyncio.run(tool.execute(**tool.args))
431
432 assert "Skill: browser-form-workflows" in second.message
433 assert second.additional["skill_instructions"]["content_included"] is True
434 assert agent.context.get_data("loaded_skills") == ["browser-form-workflows"]
435
436
437 def test_loaded_skills_extension_reattaches_missing_body_after_compaction(
438 monkeypatch, tmp_path: Path
439 ):
440 module = _load_loaded_skills_extension(monkeypatch, tmp_path)
441 agent = _FakeLoadedSkillAgent()
442 loop_data = types.SimpleNamespace(
443 protocol_persistent={},
444 extras_persistent={},
445 history_output=[
446 {
447 "ai": False,
448 "content": "Earlier history was summarized and no skill body is visible.",
449 }
450 ],
451 )
452
453 asyncio.run(module.IncludeLoadedSkills(agent).execute(loop_data))
454
455 assert len(agent.added_tool_results) == 1
456 added = agent.added_tool_results[0]
457 assert added["tool_name"] == "skills_tool"
458 assert "Skill: browser-form-workflows" in added["tool_result"]
459 assert added["skill_instructions"] == {
460 "name": "browser-form-workflows",
461 "path": str(tmp_path),
462 "source": "skills_tool:reattach",
463 "content_included": True,
464 }
465 assert loop_data.history_output[-1]["content"] == added
466
467
468 def test_loaded_skills_extension_does_not_reattach_visible_skill(
469 monkeypatch, tmp_path: Path
470 ):
471 module = _load_loaded_skills_extension(monkeypatch, tmp_path)
472 agent = _FakeLoadedSkillAgent()
473 loop_data = types.SimpleNamespace(
474 protocol_persistent={},
475 extras_persistent={},
476 history_output=[
477 {
478 "ai": False,
479 "content": {
480 "skill_instructions": {
481 "name": "browser-form-workflows",
482 "content_included": True,
483 }
484 },
485 }
486 ],
487 )
488
489 asyncio.run(module.IncludeLoadedSkills(agent).execute(loop_data))
490
491 assert agent.added_tool_results == []
492
493
494 def test_loaded_skills_extension_keeps_reattachments_under_budget(
495 monkeypatch, tmp_path: Path
496 ):
497 module = _load_loaded_skills_extension(monkeypatch, tmp_path)
498 monkeypatch.setattr(module, "SKILL_REATTACHMENT_TOKEN_BUDGET", 1)
499 agent = _FakeLoadedSkillAgent()
500 loop_data = types.SimpleNamespace(
501 protocol_persistent={},
502 extras_persistent={},
503 history_output=[],
504 )
505
506 asyncio.run(module.IncludeLoadedSkills(agent).execute(loop_data))
507
508 assert agent.added_tool_results == []
509
510
511 def test_relevant_skill_recall_uses_raw_user_message(monkeypatch):
512 queries: list[str] = []
513 module = _load_relevant_skills_extension(monkeypatch, queries)
514 agent = types.SimpleNamespace()
515 loop_data = types.SimpleNamespace(
516 iteration=0,
517 user_message=types.SimpleNamespace(
518 content={
519 "system_message": [],
520 "user_message": "Open a browser and take a screenshot.",
521 "attachments": [],
522 },
523 output_text=lambda: 'user: {"user_message": "wrapped"}',
524 ),
525 extras_temporary={},
526 )
527
528 asyncio.run(module.RecallRelevantSkills(agent).execute(loop_data=loop_data))
529
530 assert queries == ["Open a browser and take a screenshot."]
531
532
533 def test_relevant_skill_recall_skips_blocked_skills_tool(monkeypatch):
534 queries: list[str] = []
535 module = _load_relevant_skills_extension(
536 monkeypatch, queries, skills_tool_allowed=False
537 )
538 loop_data = types.SimpleNamespace(
539 iteration=0,
540 user_message=types.SimpleNamespace(
541 content="Open a browser and take a screenshot.",
542 output_text=lambda: "Open a browser and take a screenshot.",
543 ),
544 extras_temporary={},
545 )
546
547 asyncio.run(
548 module.RecallRelevantSkills(types.SimpleNamespace()).execute(
549 loop_data=loop_data
550 )
551 )
552
553 assert queries == []
554
555
556 def test_skills_tool_read_file_action_reads_inside_skill_dir(
557 monkeypatch, tmp_path: Path
558 ):
559 skill_root = tmp_path / "browser-form-workflows"
560 skill_root.mkdir()
561 (skill_root / "notes.md").write_text("Use labels before typing.\n", encoding="utf-8")
562 module = _load_skills_tool(monkeypatch, skill_root)
563 tool = module.SkillsTool(
564 _FakeAgent(),
565 "skills_tool",
566 None,
567 {
568 "action": "read_file",
569 "skill_name": "browser-form-workflows",
570 "file_path": "notes.md",
571 },
572 "",
573 None,
574 )
575
576 response = asyncio.run(tool.execute(**tool.args))
577
578 assert "Skill file: browser-form-workflows/notes.md" in response.message
579 assert "Use labels before typing." in response.message
580
581
582 def test_memory_forget_tool_imports_plugin_memory_load(monkeypatch):
583 _install_tool_stub(monkeypatch)
584 monkeypatch.syspath_prepend(str(Path.cwd()))
585
586 class FakeDb:
587 def __init__(self) -> None:
588 self.calls = []
589
590 async def delete_documents_by_query(self, **kwargs):
591 self.calls.append(kwargs)
592 return ["memory-1"]
593
594 fake_db = FakeDb()
595
596 async def get_memory(_agent):
597 return fake_db
598
599 memory_stub = types.ModuleType("plugins._memory.helpers.memory")
600 memory_stub.Memory = types.SimpleNamespace(get=get_memory)
601 monkeypatch.setitem(sys.modules, "plugins._memory.helpers.memory", memory_stub)
602
603 sys.modules.pop("plugins._memory.tools.memory_load", None)
604 sys.modules.pop("plugins._memory.tools.memory_forget", None)
605 module = importlib.import_module("plugins._memory.tools.memory_forget")
606 tool = module.MemoryForget(
607 _FakeAgent(),
608 "memory_forget",
609 None,
610 {
611 "query": "codex memory forget token",
612 "threshold": 0.99,
613 "filter": "area=='codex_sweep'",
614 },
615 "",
616 None,
617 )
618
619 response = asyncio.run(tool.execute(**tool.args))
620
621 assert response.message == "deleted 1"
622 assert fake_db.calls == [
623 {
624 "query": "codex memory forget token",
625 "threshold": 0.99,
626 "filter": "area=='codex_sweep'",
627 "include_exact": True,
628 "cascade": True,
629 }
630 ]
631
632
633 def test_memory_load_coerces_numeric_string_args(monkeypatch):
634 _install_tool_stub(monkeypatch)
635 monkeypatch.syspath_prepend(str(Path.cwd()))
636
637 class FakeDb:
638 def __init__(self) -> None:
639 self.calls = []
640
641 async def search_similarity_threshold(self, **kwargs):
642 self.calls.append(kwargs)
643 return []
644
645 fake_db = FakeDb()
646
647 async def get_memory(_agent):
648 return fake_db
649
650 memory_stub = types.ModuleType("plugins._memory.helpers.memory")
651 memory_stub.Memory = types.SimpleNamespace(get=get_memory)
652 monkeypatch.setitem(sys.modules, "plugins._memory.helpers.memory", memory_stub)
653
654 sys.modules.pop("plugins._memory.tools.memory_load", None)
655 module = importlib.import_module("plugins._memory.tools.memory_load")
656 tool = module.MemoryLoad(
657 _FakeAgent(),
658 "memory_load",
659 None,
660 {
661 "query": "smoke test project context",
662 "threshold": "0.7",
663 "limit": "3",
664 },
665 "",
666 None,
667 )
668
669 asyncio.run(tool.execute(**tool.args))
670
671 assert fake_db.calls == [
672 {
673 "query": "smoke test project context",
674 "threshold": 0.7,
675 "limit": 3,
676 "filter": "",
677 }
678 ]
679
680
681 def test_behaviour_adjustment_normalizes_duplicate_rules(monkeypatch):
682 _install_tool_stub(monkeypatch)
683 monkeypatch.syspath_prepend(str(Path.cwd()))
684
685 agent_stub = types.ModuleType("agent")
686 agent_stub.Agent = object
687 monkeypatch.setitem(sys.modules, "agent", agent_stub)
688
689 log_stub = types.ModuleType("helpers.log")
690 log_stub.LogItem = object
691 monkeypatch.setitem(sys.modules, "helpers.log", log_stub)
692
693 memory_stub = types.ModuleType("plugins._memory.helpers.memory")
694 memory_stub.get_memory_subdir_abs = lambda agent: "/tmp"
695 monkeypatch.setitem(sys.modules, "plugins._memory.helpers.memory", memory_stub)
696
697 sys.modules.pop("plugins._memory.tools.behaviour_adjustment", None)
698 module = importlib.import_module("plugins._memory.tools.behaviour_adjustment")
699
700 rules = module.normalize_ruleset(
701 "## Behavioral rules\n"
702 "* Favor Linux commands.\n"
703 "* Token rule.## Behavioral rules\n"
704 "* Favor Linux commands.\n"
705 "* Token rule."
706 )
707
708 assert rules == "## Behavioral rules\n* Favor Linux commands.\n* Token rule.\n"
709
710
711 def test_behaviour_prompts_preserve_exact_rules_and_avoid_promptinclude():
712 behaviour_prompt_path = Path(
713 "plugins/_memory/prompts/agent.system.tool.behaviour.md"
714 )
715 behaviour_prompt = behaviour_prompt_path.read_text(encoding="utf-8")
716 merge_prompt = Path("prompts/behaviour.merge.sys.md").read_text(
717 encoding="utf-8"
718 )
719 promptinclude_prompt = Path(
720 "plugins/_promptinclude/prompts/agent.system.promptinclude.md"
721 ).read_text(encoding="utf-8")
722
723 assert "exact-response rules" in behaviour_prompt
724 assert "preserve it verbatim" in behaviour_prompt
725 assert not Path("prompts/agent.system.tool.behaviour.md").exists()
726 assert "respond exactly with a phrase" in merge_prompt
727 assert "use behaviour_adjustment, not promptinclude files" in promptinclude_prompt
728
729
730 def _load_a2a_chat_tool(monkeypatch):
731 _install_tool_stub(monkeypatch)
732 sys.modules.pop("tools.a2a_chat", None)
733 return importlib.import_module("tools.a2a_chat")
734
735
736 def test_a2a_extracts_latest_assistant_text_from_history(monkeypatch):
737 module = _load_a2a_chat_tool(monkeypatch)
738
739 final = {
740 "result": {
741 "history": [
742 {
743 "role": "user",
744 "parts": [{"kind": "text", "text": "what is 2+2?"}],
745 },
746 {
747 "role": "assistant",
748 "parts": [{"kind": "text", "text": "4"}],
749 },
750 ]
751 }
752 }
753
754 assert module._extract_latest_assistant_text(final) == "4"
755
756
757 def test_a2a_extracts_status_or_artifact_text_when_history_is_empty(monkeypatch):
758 module = _load_a2a_chat_tool(monkeypatch)
759
760 status_final = {
761 "result": {
762 "status": {
763 "message": {
764 "parts": [{"kind": "text", "text": "status answer"}]
765 }
766 }
767 }
768 }
769 artifact_final = {
770 "result": {
771 "artifacts": [
772 {"parts": [{"kind": "text", "text": "artifact answer"}]}
773 ]
774 }
775 }
776
777 assert module._extract_latest_assistant_text(status_final) == "status answer"
778 assert module._extract_latest_assistant_text(artifact_final) == "artifact answer"
779
780
781 def test_a2a_session_key_normalizes_explicit_a2a_path(monkeypatch):
782 module = _load_a2a_chat_tool(monkeypatch)
783
784 assert module._session_key("http://localhost:32080/a2a") == "http://localhost:32080"
785 assert module._session_key("http://localhost:32080") == "http://localhost:32080"
786
787
788 def test_a2a_empty_response_message_is_explicit_failure(monkeypatch):
789 module = _load_a2a_chat_tool(monkeypatch)
790
791 assert module._extract_latest_assistant_text({"result": {"history": []}}) == ""
792 assert "failed" in module.A2A_EMPTY_RESPONSE_ERROR
793 assert "not success" in module.A2A_EMPTY_RESPONSE_ERROR
794
795
796 def test_notify_user_prompt_documents_numeric_priority_values():
797 prompt = Path("prompts/agent.system.tool.notify_user.md").read_text(
798 encoding="utf-8"
799 )
800
801 assert "priority values: `20` high urgency, `10` normal urgency" in prompt
802
803
804 def test_tool_prompts_prevent_top_level_multi_tool():
805 tools_prompt = Path("prompts/agent.system.tools.md").read_text(encoding="utf-8")
806 communication_prompt = Path("prompts/agent.system.main.communication.md").read_text(
807 encoding="utf-8"
808 )
809 browser_prompt = Path("plugins/_browser/prompts/agent.system.tool.browser.md").read_text(
810 encoding="utf-8"
811 )
812
813 assert "Do not invent top-level `multi` or generic batch tools" in tools_prompt
814 assert "listed wrapper for independent concurrent calls is `parallel`" in tools_prompt
815 assert "never an action name such as `read`, `write`, `terminal`, or `multi`" in communication_prompt
816 assert "independent operations concurrently" in communication_prompt
817 assert 'Never use `tool_name: "multi"`' in browser_prompt
818
819
820 def test_local_model_tool_use_guide_stays_prompt_profile_plugin_only():
821 guide = Path("docs/guides/local-model-tool-use.md").read_text(encoding="utf-8")
822
823 assert "Tiny Local" in guide
824 assert "agents/tiny-local/" in guide
825 assert "*.promptinclude.md" in guide
826 assert "Do not change `agent.py`" in guide
827 assert "Do not change `helpers/extract_tools.py`" in guide
828 assert "Use exactly these top-level fields: `tool_name` and `tool_args`." in guide
829 assert '{"tool_name":"response","tool_args":{"text":"Done."}}' in guide
830 assert "If the user says \"proceed\", \"continue\", \"go ahead\", or similar" in guide
831 assert "call the next appropriate tool instead of replying with a promise or status update" in guide
832 assert "Do not create parser repair code for this workflow." in guide
833
834
835 def _load_scheduler_tool(monkeypatch):
836 _install_tool_stub(monkeypatch)
837
838 scheduler_stub = types.ModuleType("helpers.task_scheduler")
839 scheduler_stub.TaskScheduler = object
840 scheduler_stub.ScheduledTask = type("ScheduledTask", (), {})
841 scheduler_stub.AdHocTask = type("AdHocTask", (), {})
842 scheduler_stub.PlannedTask = type("PlannedTask", (), {})
843 scheduler_stub.serialize_task = lambda task: {}
844 scheduler_stub.parse_datetime = lambda value: None
845 scheduler_stub.parse_task_plan = lambda value: None
846 scheduler_stub.serialize_datetime = lambda value: value
847 scheduler_stub.TaskState = types.SimpleNamespace(
848 IDLE="idle",
849 RUNNING="running",
850 )
851 scheduler_stub.TaskSchedule = type("TaskSchedule", (), {})
852 scheduler_stub.TaskPlan = type("TaskPlan", (), {})
853 monkeypatch.setitem(sys.modules, "helpers.task_scheduler", scheduler_stub)
854
855 agent_stub = types.ModuleType("agent")
856 agent_stub.AgentContext = types.SimpleNamespace(
857 get=lambda *args, **kwargs: None,
858 remove=lambda *args, **kwargs: None,
859 )
860 monkeypatch.setitem(sys.modules, "agent", agent_stub)
861
862 persist_chat_stub = types.ModuleType("helpers.persist_chat")
863 persist_chat_stub.remove_chat = lambda *args, **kwargs: None
864 monkeypatch.setitem(sys.modules, "helpers.persist_chat", persist_chat_stub)
865
866 projects_stub = types.ModuleType("helpers.projects")
867 projects_stub.get_context_project_name = lambda context: ""
868 projects_stub.load_basic_project_data = lambda project: {}
869 monkeypatch.setitem(sys.modules, "helpers.projects", projects_stub)
870
871 sys.modules.pop("tools.scheduler", None)
872 return importlib.import_module("tools.scheduler")
873
874
875 def test_scheduler_accepts_action_alias(monkeypatch):
876 module = _load_scheduler_tool(monkeypatch)
877 tool = module.SchedulerTool(
878 _FakeAgent(),
879 "scheduler",
880 None,
881 {"action": "list_tasks"},
882 "",
883 None,
884 )
885
886 async def list_tasks(**kwargs):
887 return module.Response("listed", False)
888
889 tool.list_tasks = list_tasks
890
891 response = asyncio.run(tool.execute(**tool.args))
892
893 assert response.message == "listed"
894
895
896 def test_scheduler_requires_action_field(monkeypatch):
897 module = _load_scheduler_tool(monkeypatch)
898 tool = module.SchedulerTool(
899 _FakeAgent(),
900 "scheduler",
901 "list_tasks",
902 {},
903 "",
904 None,
905 )
906
907 response = asyncio.run(tool.execute(**tool.args))
908
909 assert "Unknown scheduler action" in response.message
910
911
912 def test_scheduler_create_defaults_to_dedicated_context(monkeypatch):
913 module = _load_scheduler_tool(monkeypatch)
914
915 class FakeTaskSchedule:
916 def __init__(self, **kwargs):
917 self.__dict__.update(kwargs)
918
919 def to_crontab(self):
920 return f"{self.minute} {self.hour} {self.day} {self.month} {self.weekday}"
921
922 class FakeScheduledTask:
923 @classmethod
924 def create(cls, **kwargs):
925 task = cls()
926 task.uuid = "task-1"
927 task.context_id = kwargs.get("context_id")
928 task.schedule = kwargs.get("schedule")
929 return task
930
931 class FakeScheduler:
932 def __init__(self):
933 self.added = None
934
935 async def add_task(self, task):
936 self.added = task
937
938 fake_scheduler = FakeScheduler()
939 module.TaskSchedule = FakeTaskSchedule
940 module.ScheduledTask = FakeScheduledTask
941 module.TaskScheduler = types.SimpleNamespace(get=lambda: fake_scheduler)
942 tool = module.SchedulerTool(
943 _FakeAgent(),
944 "scheduler",
945 None,
946 {
947 "action": "create_scheduled_task",
948 "name": "check stuff",
949 "prompt": "tell me if anything changed",
950 "schedule": {"minute": "0", "hour": "9", "day": "*", "month": "*", "weekday": "*"},
951 },
952 "",
953 None,
954 )
955
956 response = asyncio.run(tool.execute(**tool.args))
957
958 assert "created" in response.message
959 assert fake_scheduler.added.context_id is None
960
961
962 def test_scheduler_local_timezone_alias_uses_current_user_timezone(monkeypatch):
963 module = _load_scheduler_tool(monkeypatch)
964
965 class FakeTaskSchedule:
966 def __init__(self, **kwargs):
967 self.__dict__.update(kwargs)
968
969 module.TaskSchedule = FakeTaskSchedule
970 module.Localization = types.SimpleNamespace(
971 get=lambda: types.SimpleNamespace(get_timezone=lambda: "Europe/Rome")
972 )
973
974 assert module._schedule_timezone({"schedule": {"timezone": "local"}}) == "Europe/Rome"
975 schedule = module._task_schedule_from_input(
976 {"minute": "30", "hour": "9", "day": "*", "month": "*", "weekday": "*", "timezone": "current"}
977 )
978
979 assert schedule.timezone == "Europe/Rome"
980
981
982 def test_scheduler_invalid_timezone_returns_repairable_message(monkeypatch):
983 module = _load_scheduler_tool(monkeypatch)
984 tool = module.SchedulerTool(
985 _FakeAgent(),
986 "scheduler",
987 None,
988 {
989 "action": "create_scheduled_task",
990 "name": "bad timezone",
991 "prompt": "tell me something",
992 "schedule": {
993 "minute": "0",
994 "hour": "9",
995 "day": "*",
996 "month": "*",
997 "weekday": "*",
998 "timezone": "Mars/Base",
999 },
1000 },
1001 "",
1002 None,
1003 )
1004
1005 response = asyncio.run(tool.execute(**tool.args))
1006
1007 assert "Invalid timezone: Mars/Base" in response.message
1008
1009
1010 def test_scheduler_prompt_includes_update_timezone_and_dedicated_context():
1011 project_root = Path(__file__).resolve().parents[1]
1012 text = (
1013 project_root / "prompts/agent.system.tool.scheduler.md"
1014 ).read_text(encoding="utf-8")
1015
1016 assert "update_task" in text
1017 assert "timezone" in text
1018 assert "IANA" in text
1019 assert "dedicated context" in text
1020
1021
1022 def test_skills_prompt_renders_catalog_placeholder():
1023 project_root = Path(__file__).resolve().parents[1]
1024 text = (project_root / "prompts/agent.system.skills.md").read_text(
1025 encoding="utf-8"
1026 )
1027
1028 assert "{{skills}}" in text
1029
1030
1031 def test_corrected_tool_prompts_only_teach_action_contract():
1032 project_root = Path(__file__).resolve().parents[1]
1033 prompt_paths = [
1034 project_root / "plugins/_text_editor/prompts/agent.system.tool.text_editor.md",
1035 project_root / "prompts/agent.system.tool.skills.md",
1036 project_root / "prompts/agent.system.tool.scheduler.md",
1037 project_root / "plugins/_a0_connector/prompts/agent.system.tool.text_editor_remote.md",
1038 project_root / "plugins/_office/prompts/agent.system.tool.office_artifact.md",
1039 project_root / "plugins/_office/skills/office-artifacts/SKILL.md",
1040 project_root / "plugins/_office/skills/markdown-documents/SKILL.md",
1041 project_root / "plugins/_office/skills/writer-documents/SKILL.md",
1042 project_root / "plugins/_office/skills/calc-spreadsheets/SKILL.md",
1043 project_root / "plugins/_office/skills/impress-presentations/SKILL.md",
1044 ]
1045 forbidden = (
1046 "text_editor:",
1047 "skills_tool:",
1048 "scheduler:",
1049 "office_artifact:",
1050 "`method`",
1051 "`op`",
1052 "`operation`",
1053 "alias",
1054 )
1055
1056 for path in prompt_paths:
1057 text = path.read_text(encoding="utf-8")
1058 assert "action" in text
1059 for token in forbidden:
1060 assert token not in text
1061 if "document" in path.name or "_office/skills" in str(path):
1062 assert "faux UI action labels" in text
1063 assert "Open document" in text
1064 assert "Download file" in text
1065 assert "Canvas was not opened automatically" not in text
1066 assert "Open Document, or Desktop edit actions" not in text
1067
1068
1069 def test_computer_use_remote_is_runtime_checked_standard_tool():
1070 project_root = Path(__file__).resolve().parents[1]
1071 standard_prompt_path = (
1072 project_root
1073 / "plugins/_a0_connector/prompts/agent.system.tool.computer_use_remote.md"
1074 )
1075 standard_prompt_text = standard_prompt_path.read_text(encoding="utf-8")
1076 skill_text = (
1077 project_root
1078 / "plugins/_a0_connector/skills/host-computer-use/SKILL.md"
1079 ).read_text(encoding="utf-8")
1080 macos_skill_text = (
1081 project_root
1082 / "plugins/_a0_connector/skills/host-computer-use-macos/SKILL.md"
1083 ).read_text(encoding="utf-8")
1084 windows_skill_text = (
1085 project_root
1086 / "plugins/_a0_connector/skills/host-computer-use-windows/SKILL.md"
1087 ).read_text(encoding="utf-8")
1088
1089 assert standard_prompt_path.exists()
1090 assert not (
1091 project_root
1092 / "plugins/_a0_connector/prompts/agent.system.runtime_tool.computer_use_remote.md"
1093 ).exists()
1094 assert '"tool_name": "computer_use_remote"' in standard_prompt_text
1095 assert "not scoped to a single chat context" in standard_prompt_text
1096 assert "checked when the tool runs" in standard_prompt_text
1097 assert "visual verification is unavailable" in standard_prompt_text
1098 assert "host-computer-use-macos" in standard_prompt_text
1099 assert "host-computer-use-windows" in standard_prompt_text
1100 assert "ax_snapshot" not in standard_prompt_text
1101 assert "ax_action" not in standard_prompt_text
1102 assert "uia_snapshot" not in standard_prompt_text
1103 assert "uia_action" not in standard_prompt_text
1104 assert '"tool_name": "computer_use_remote"' in skill_text
1105 assert "ax_snapshot" not in skill_text
1106 assert "ax_action" not in skill_text
1107 assert "uia_snapshot" not in skill_text
1108 assert "uia_action" not in skill_text
1109 assert '"tool_name": "computer_use_remote"' in macos_skill_text
1110 assert "ax_snapshot" in macos_skill_text
1111 assert "ax_action" in macos_skill_text
1112 assert '"tool_name": "computer_use_remote"' in windows_skill_text
1113 assert "uia_snapshot" in windows_skill_text
1114 assert "uia_action" in windows_skill_text
1115 assert "focus_window" in windows_skill_text
1116 assert "If a node offers `invoke`, use `invoke`, not `click`" in windows_skill_text
1117 assert "Backend-specific macOS guidance" in macos_skill_text
1118 assert "Backend-specific Windows guidance" in windows_skill_text
1119 assert "Beta desktop control" in skill_text
1120
1121
1122 def test_computer_use_remote_start_session_reports_backend_features_and_macos_skill(monkeypatch):
1123 module = _load_computer_use_remote_tool(monkeypatch)
1124 tool = object.__new__(module.ComputerUseRemote)
1125
1126 message = tool._extract_result(
1127 "start_session",
1128 {
1129 "ok": True,
1130 "result": {
1131 "session_id": "s1",
1132 "width": 1920,
1133 "height": 1080,
1134 "backend_id": "macos",
1135 "backend_family": "macos",
1136 "features": [
1137 "accessibility-tree-snapshot",
1138 "accessibility-structural-targeting",
1139 ],
1140 },
1141 },
1142 )
1143
1144 assert "session_id=s1" in message
1145 assert "backend=macos/macos" in message
1146 assert "features=accessibility-tree-snapshot, accessibility-structural-targeting" in message
1147 assert "host-computer-use-macos" in message
1148
1149
1150 def test_computer_use_remote_start_session_reports_backend_features_and_windows_skill(monkeypatch):
1151 module = _load_computer_use_remote_tool(monkeypatch)
1152 tool = object.__new__(module.ComputerUseRemote)
1153
1154 message = tool._extract_result(
1155 "start_session",
1156 {
1157 "ok": True,
1158 "result": {
1159 "session_id": "s1",
1160 "width": 3840,
1161 "height": 2160,
1162 "backend_id": "windows",
1163 "backend_family": "windows",
1164 "features": [
1165 "uia-tree-snapshot",
1166 "uia-structural-targeting",
1167 ],
1168 },
1169 },
1170 )
1171
1172 assert "session_id=s1" in message
1173 assert "backend=windows/windows" in message
1174 assert "features=uia-tree-snapshot, uia-structural-targeting" in message
1175 assert "host-computer-use-windows" in message
1176
1177
1178 def test_computer_use_remote_forwards_linux_window_scope_and_type_guard(monkeypatch):
1179 module = _load_computer_use_remote_tool(monkeypatch)
1180 tool = object.__new__(module.ComputerUseRemote)
1181 window_id = "atspi-pid:57929:path:20.0"
1182
1183 tool.args = {
1184 "action": "ax_snapshot",
1185 "pid": 57929,
1186 "window_id": window_id,
1187 "max_depth": 4,
1188 "max_nodes": 80,
1189 }
1190 snapshot = tool._build_payload(op_id="op-snapshot", context_id="ctx", action="ax_snapshot")
1191 tool.args = {"action": "type", "window_id": window_id, "text": "hello", "submit": True}
1192 typed = tool._build_payload(op_id="op-type", context_id="ctx", action="type")
1193
1194 assert snapshot["pid"] == 57929
1195 assert snapshot["window_id"] == window_id
1196 assert snapshot["max_depth"] == 4
1197 assert snapshot["max_nodes"] == 80
1198 assert typed["window_id"] == window_id
1199 assert typed["text"] == "hello"
1200 assert typed["submit"] is True
1201
1202
1203 def test_computer_use_remote_receipts_separate_injection_from_verified_result(monkeypatch):
1204 module = _load_computer_use_remote_tool(monkeypatch)
1205 tool = object.__new__(module.ComputerUseRemote)
1206 window_id = "atspi-pid:57929:path:20.0"
1207
1208 verified = tool._extract_result(
1209 "type",
1210 {
1211 "ok": True,
1212 "result": {
1213 "text": "hello",
1214 "window_id": window_id,
1215 "focus_verified": True,
1216 },
1217 },
1218 )
1219 unverified = tool._extract_result(
1220 "type",
1221 {"ok": True, "result": {"text": "hello"}},
1222 )
1223 focused = tool._extract_result(
1224 "element_action",
1225 {
1226 "ok": True,
1227 "result": {
1228 "operation": "focus",
1229 "target": {"element_index": 0, "role": "frame", "title": "Discord"},
1230 "requested_dispatch": "foreground",
1231 "actual_dispatch": "foreground",
1232 "focus_verified": True,
1233 },
1234 },
1235 )
1236 scoped = tool._extract_result(
1237 "ax_snapshot",
1238 {
1239 "ok": True,
1240 "result": {
1241 "app": {"name": "Discord"},
1242 "tree": {"role": "frame", "title": "Discord"},
1243 "node_count": 2,
1244 "window_id": window_id,
1245 "scoped": True,
1246 },
1247 },
1248 )
1249
1250 assert f"verified active window_id={window_id}" in verified
1251 assert "destination was not verified" in unverified
1252 assert "focus_verified=true" in focused
1253 assert f"scoped to window_id={window_id}" in scoped
1254
1255
1256 def test_computer_use_remote_capture_artifact_is_chat_scoped(monkeypatch, tmp_path: Path):
1257 module = _load_computer_use_remote_tool(monkeypatch)
1258
1259 def fake_get_abs_path(*parts):
1260 return str(tmp_path.joinpath(*parts))
1261
1262 def fake_normalize_a0_path(path):
1263 return "/a0/" + str(Path(path).relative_to(tmp_path)).replace("\\", "/")
1264
1265 monkeypatch.setattr(module.chat_media.files, "get_abs_path", fake_get_abs_path)
1266 monkeypatch.setattr(module.chat_media.files, "normalize_a0_path", fake_normalize_a0_path)
1267
1268 tool = object.__new__(module.ComputerUseRemote)
1269 tool.agent = types.SimpleNamespace(context=types.SimpleNamespace(id="ctx-computer"))
1270
1271 display_ref, capture_id = tool._resolve_capture_ref(
1272 {
1273 "artifact": {
1274 "filename": "capture.png",
1275 "mime": "image/png",
1276 "encoding": "base64",
1277 "data": "ZmFrZQ==",
1278 },
1279 }
1280 )
1281
1282 assert display_ref.startswith("/a0/usr/chats/ctx-computer/screenshots/computer-use/capture-")
1283 stored_path = tmp_path / display_ref.removeprefix("/a0/")
1284 assert stored_path.read_bytes() == b"fake"
1285 assert capture_id == stored_path.stem