caudate-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. api/__init__.py +5 -0
  2. api/anthropic_compat.py +1518 -0
  3. api/artifact_viewer.py +366 -0
  4. api/caudate_middleware.py +618 -0
  5. api/forge_bootstrapper_routes.py +377 -0
  6. api/forge_routes.py +630 -0
  7. api/forge_system_routes.py +294 -0
  8. api/openai_compat.py +1993 -0
  9. api/server.py +667 -0
  10. api/storyboard_page.py +677 -0
  11. caudate_cli-0.1.0.dist-info/METADATA +354 -0
  12. caudate_cli-0.1.0.dist-info/RECORD +153 -0
  13. caudate_cli-0.1.0.dist-info/WHEEL +5 -0
  14. caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
  15. caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  16. caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
  17. cognos_mcp/__init__.py +4 -0
  18. cognos_mcp/bridge.py +41 -0
  19. cognos_mcp/client.py +70 -0
  20. cognos_mcp/config.py +49 -0
  21. cognos_mcp/server.py +66 -0
  22. config.py +82 -0
  23. core/__init__.py +0 -0
  24. core/agent.py +468 -0
  25. core/agentic_loop.py +731 -0
  26. core/anthropic_auth.py +91 -0
  27. core/background.py +113 -0
  28. core/banner.py +134 -0
  29. core/bootstrap.py +292 -0
  30. core/citations.py +131 -0
  31. core/compaction.py +109 -0
  32. core/constitution.py +198 -0
  33. core/diff_viewer.py +87 -0
  34. core/export.py +85 -0
  35. core/file_refs.py +119 -0
  36. core/files.py +199 -0
  37. core/hooks.py +209 -0
  38. core/image.py +599 -0
  39. core/input.py +91 -0
  40. core/loop.py +238 -0
  41. core/memory_md.py +147 -0
  42. core/notifications.py +99 -0
  43. core/ownership.py +181 -0
  44. core/paste.py +81 -0
  45. core/permissions.py +210 -0
  46. core/plan_mode.py +215 -0
  47. core/sandbox_prompt.py +185 -0
  48. core/scheduler.py +195 -0
  49. core/schemas.py +202 -0
  50. core/session.py +90 -0
  51. core/settings.py +132 -0
  52. core/skills.py +398 -0
  53. core/slash_commands.py +977 -0
  54. core/statusline.py +61 -0
  55. core/subagent.py +300 -0
  56. core/thinking.py +50 -0
  57. core/updater.py +122 -0
  58. core/usage.py +109 -0
  59. core/worktree.py +93 -0
  60. execution/__init__.py +0 -0
  61. execution/executor.py +329 -0
  62. execution/plugins.py +108 -0
  63. execution/tools/__init__.py +0 -0
  64. execution/tools/agent_tool.py +107 -0
  65. execution/tools/agentic_tool.py +297 -0
  66. execution/tools/artifact_tool.py +191 -0
  67. execution/tools/ask_user_question_tool.py +137 -0
  68. execution/tools/base.py +81 -0
  69. execution/tools/calculator_tool.py +137 -0
  70. execution/tools/cognos_card_tool.py +124 -0
  71. execution/tools/cron_tool.py +215 -0
  72. execution/tools/datetime_tool.py +215 -0
  73. execution/tools/describe_image_tool.py +161 -0
  74. execution/tools/draw_tool.py +164 -0
  75. execution/tools/edit_image_tool.py +262 -0
  76. execution/tools/edit_tool.py +245 -0
  77. execution/tools/file_tool.py +90 -0
  78. execution/tools/find_anywhere_tool.py +255 -0
  79. execution/tools/forge_feature_tools.py +377 -0
  80. execution/tools/glob_tool.py +59 -0
  81. execution/tools/grep_tool.py +89 -0
  82. execution/tools/http_request_tool.py +224 -0
  83. execution/tools/load_skill_tool.py +104 -0
  84. execution/tools/longcat_avatar_tool.py +384 -0
  85. execution/tools/mcp_tool.py +100 -0
  86. execution/tools/notebook_tool.py +279 -0
  87. execution/tools/openapi_tool.py +440 -0
  88. execution/tools/plan_mode_tool.py +95 -0
  89. execution/tools/push_notification_tool.py +157 -0
  90. execution/tools/python_tool.py +61 -0
  91. execution/tools/respond_tool.py +40 -0
  92. execution/tools/sandbox_tool.py +378 -0
  93. execution/tools/search_tool.py +153 -0
  94. execution/tools/semantic_search_tool.py +106 -0
  95. execution/tools/shell_tool.py +283 -0
  96. execution/tools/speak_tool.py +134 -0
  97. execution/tools/storyboard_tool.py +727 -0
  98. execution/tools/system_info_tool.py +212 -0
  99. execution/tools/task_tool.py +323 -0
  100. execution/tools/think_tool.py +49 -0
  101. execution/tools/transcribe_audio_tool.py +86 -0
  102. execution/tools/update_memory_tool.py +92 -0
  103. execution/tools/web_fetch_tool.py +82 -0
  104. execution/tools/worktree_tool.py +174 -0
  105. llm/__init__.py +0 -0
  106. llm/fallback.py +116 -0
  107. llm/models.py +320 -0
  108. llm/provider.py +1356 -0
  109. llm/router.py +373 -0
  110. main.py +1889 -0
  111. memory/__init__.py +0 -0
  112. memory/episodic.py +99 -0
  113. memory/procedural.py +145 -0
  114. memory/semantic.py +71 -0
  115. memory/working.py +64 -0
  116. nn/__init__.py +43 -0
  117. nn/auto_evolve.py +245 -0
  118. nn/caudate.py +136 -0
  119. nn/config.py +141 -0
  120. nn/consolidator.py +81 -0
  121. nn/data.py +1635 -0
  122. nn/encoder.py +258 -0
  123. nn/forge_advisor.py +303 -0
  124. nn/format.py +235 -0
  125. nn/heads.py +432 -0
  126. nn/observer.py +994 -0
  127. nn/policy.py +214 -0
  128. nn/runtime.py +343 -0
  129. nn/scorer.py +175 -0
  130. nn/trainer.py +515 -0
  131. nn/vision.py +352 -0
  132. personality/__init__.py +23 -0
  133. personality/engine.py +129 -0
  134. personality/identity.py +144 -0
  135. personality/inner_voice.py +100 -0
  136. personality/mood.py +205 -0
  137. planning/__init__.py +0 -0
  138. planning/dev_server.py +221 -0
  139. planning/forge_models.py +718 -0
  140. planning/orchestrator.py +1363 -0
  141. planning/planner.py +451 -0
  142. planning/task_graph.py +61 -0
  143. reflection/__init__.py +0 -0
  144. reflection/meta_learner.py +156 -0
  145. reflection/reflector.py +127 -0
  146. ui/__init__.py +5 -0
  147. ui/display.py +88 -0
  148. voice/__init__.py +0 -0
  149. voice/conversation.py +125 -0
  150. voice/listener.py +111 -0
  151. voice/speaker.py +59 -0
  152. voice/stt.py +126 -0
  153. voice/tts.py +214 -0
core/worktree.py ADDED
@@ -0,0 +1,93 @@
1
+ """Worktree isolation — give a subagent its own checkout to mutate.
2
+
3
+ A worktree is a separate working copy backed by the same `.git/` repo,
4
+ created with `git worktree add`. The subagent gets full read/write
5
+ access to its own copy without touching the parent's working tree. When
6
+ the subagent finishes, the worktree is either kept (if it produced
7
+ changes) or cleaned up (if it didn't), mirroring the SDK's contract.
8
+
9
+ Two failure modes are handled gracefully:
10
+ - The repo is dirty in a way that blocks `worktree add` → return
11
+ `None`, caller should fall back to the shared tree.
12
+ - `git` isn't installed or the cwd isn't a git repo → same.
13
+
14
+ Callers should treat worktree creation as best-effort: a failure is
15
+ not an error, just a missed optimization.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import logging
22
+ import shutil
23
+ import tempfile
24
+ import uuid
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ @dataclass
32
+ class Worktree:
33
+ path: Path
34
+ branch: str
35
+ parent_repo: Path
36
+
37
+ async def has_changes(self) -> bool:
38
+ """Did the subagent write anything?"""
39
+ rc, out, _ = await _git(self.path, "status", "--porcelain")
40
+ return rc == 0 and bool(out.strip())
41
+
42
+ async def cleanup(self, force: bool = False) -> None:
43
+ """Remove the worktree. Skips if it has uncommitted changes (unless force)."""
44
+ if not force and await self.has_changes():
45
+ logger.info(
46
+ f"Keeping worktree {self.path} — has uncommitted changes"
47
+ )
48
+ return
49
+ rc, _, err = await _git(
50
+ self.parent_repo, "worktree", "remove", "--force", str(self.path),
51
+ )
52
+ if rc != 0:
53
+ logger.debug(f"worktree remove failed: {err}")
54
+ shutil.rmtree(self.path, ignore_errors=True)
55
+ # Best-effort branch delete
56
+ await _git(self.parent_repo, "branch", "-D", self.branch)
57
+
58
+
59
+ async def create_worktree(
60
+ parent_repo: Path | None = None,
61
+ base_branch: str = "HEAD",
62
+ ) -> Worktree | None:
63
+ """Create a fresh worktree off `base_branch`. Returns None on any failure."""
64
+ repo = parent_repo or Path.cwd()
65
+ if shutil.which("git") is None:
66
+ return None
67
+
68
+ rc, out, _ = await _git(repo, "rev-parse", "--show-toplevel")
69
+ if rc != 0:
70
+ return None
71
+ repo = Path(out.strip())
72
+
73
+ branch = f"cognos/agent-{uuid.uuid4().hex[:8]}"
74
+ tmp = Path(tempfile.mkdtemp(prefix="cognos-wt-"))
75
+ rc, _, err = await _git(
76
+ repo, "worktree", "add", "-b", branch, str(tmp), base_branch,
77
+ )
78
+ if rc != 0:
79
+ logger.debug(f"worktree add failed: {err}")
80
+ shutil.rmtree(tmp, ignore_errors=True)
81
+ return None
82
+ return Worktree(path=tmp, branch=branch, parent_repo=repo)
83
+
84
+
85
+ async def _git(cwd: Path, *args: str) -> tuple[int, str, str]:
86
+ proc = await asyncio.create_subprocess_exec(
87
+ "git", *args,
88
+ cwd=str(cwd),
89
+ stdout=asyncio.subprocess.PIPE,
90
+ stderr=asyncio.subprocess.PIPE,
91
+ )
92
+ out, err = await proc.communicate()
93
+ return proc.returncode or 0, out.decode(errors="ignore"), err.decode(errors="ignore")
execution/__init__.py ADDED
File without changes
execution/executor.py ADDED
@@ -0,0 +1,329 @@
1
+ """Executor — manages tool registry and executes tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import time
8
+ from typing import Any
9
+
10
+ from core.schemas import Task, ToolResult, ToolResultStatus, Episode
11
+ from execution.tools.base import BaseTool
12
+ from execution.tools.shell_tool import PowerShellTool, ShellTool
13
+ from execution.tools.file_tool import FileReadTool, FileWriteTool
14
+ from execution.tools.edit_tool import EditTool, FillInMiddleTool
15
+ from execution.tools.glob_tool import GlobTool
16
+ from execution.tools.grep_tool import GrepTool
17
+ from execution.tools.notebook_tool import NotebookEditTool
18
+ from execution.tools.ask_user_question_tool import AskUserQuestionTool
19
+ from execution.tools.push_notification_tool import PushNotificationTool
20
+ from execution.tools.cron_tool import CronCreateTool, CronDeleteTool, CronListTool
21
+ from execution.tools.task_tool import (
22
+ TaskCreateTool, TaskGetTool, TaskListTool, TaskOutputTool, TaskStopTool,
23
+ )
24
+ from execution.tools.worktree_tool import EnterWorktreeTool, ExitWorktreeTool
25
+ from execution.tools.mcp_tool import MCPListServersTool
26
+ from execution.tools.plan_mode_tool import EnterPlanModeTool, ExitPlanModeTool
27
+ from execution.tools.find_anywhere_tool import FindAnywhereTool
28
+ from execution.tools.system_info_tool import SystemInfoTool
29
+ from execution.tools.update_memory_tool import UpdateMemoryTool
30
+ from execution.tools.artifact_tool import ArtifactTool
31
+ from execution.tools.calculator_tool import CalculatorTool
32
+ from execution.tools.datetime_tool import DateTimeTool
33
+ from execution.tools.semantic_search_tool import SemanticSearchTool
34
+ from execution.tools.describe_image_tool import DescribeImageTool
35
+ from execution.tools.transcribe_audio_tool import TranscribeAudioTool
36
+ from execution.tools.speak_tool import SpeakTool
37
+ from execution.tools.http_request_tool import HttpRequestTool
38
+ from execution.tools.sandbox_tool import SandboxTool
39
+ from execution.tools.agentic_tool import AgenticTool, AgenticBrowseTool
40
+ from execution.tools.openapi_tool import OpenAPITool
41
+ from execution.tools.load_skill_tool import LoadSkillTool, RescanSkillsTool
42
+ from execution.tools.cognos_card_tool import CognosCardTool
43
+ from execution.tools.search_tool import WebSearchTool
44
+ from execution.tools.web_fetch_tool import WebFetchTool
45
+ from execution.tools.python_tool import PythonExecTool
46
+ from execution.tools.respond_tool import RespondTool
47
+ from execution.tools.think_tool import ThinkTool
48
+ from execution.tools.agent_tool import AgentTool
49
+ from execution.tools.draw_tool import DrawTool
50
+ from execution.tools.edit_image_tool import EditImageTool
51
+ from execution.tools.longcat_avatar_tool import LongCatAvatarTool
52
+ from execution.tools.storyboard_tool import StoryboardTool
53
+
54
+
55
+ # Legacy tool names mapped to their current canonical names. Kept for
56
+ # backward-compat with plans/strategies stored from Phase 1.
57
+ _LEGACY_ALIASES = {
58
+ "shell": "Bash",
59
+ "file_read": "Read",
60
+ "file_write": "Write",
61
+ "web_search": "WebSearch",
62
+ "python_exec": "PythonExec",
63
+ "respond": "Respond",
64
+ "think": "Think",
65
+ }
66
+
67
+ logger = logging.getLogger(__name__)
68
+
69
+
70
+ class Executor:
71
+ """Tool registry and task execution engine."""
72
+
73
+ def __init__(self, llm=None, load_plugins: bool = True):
74
+ self._tools: dict[str, BaseTool] = {}
75
+ self._llm = llm
76
+ self._register_defaults()
77
+ if load_plugins:
78
+ self._load_plugins()
79
+
80
+ def _load_plugins(self) -> None:
81
+ """Auto-load community plugins from PLUGINS_DIR."""
82
+ try:
83
+ from config import PLUGINS_DIR
84
+ from execution.plugins import load_plugins as _load
85
+ count = _load(PLUGINS_DIR, self)
86
+ if count:
87
+ logger.info(f"Loaded {count} plugin tool(s) from {PLUGINS_DIR}")
88
+ except Exception as e:
89
+ logger.warning(f"Plugin loader failed: {e}")
90
+
91
+ def _register_defaults(self) -> None:
92
+ """Register built-in tools."""
93
+ think_tool = ThinkTool(llm=self._llm)
94
+ agent_tool = AgentTool() # manager wired later by CognosAgent
95
+ # Read image-gen config from settings so users can swap model
96
+ # (e.g. FLUX.1-schnell vs SDXL-Turbo) without code changes.
97
+ # Falls back to DrawTool's own defaults if no `image` block.
98
+ try:
99
+ from core.settings import Settings
100
+ _img_cfg = Settings.load().get("image", {}) or {}
101
+ except Exception as e:
102
+ logger.warning(f"Failed to load image settings: {e}")
103
+ _img_cfg = {}
104
+ _img_backend = _img_cfg.get("backend", "diffusers")
105
+ _img_kwargs = {k: v for k, v in _img_cfg.items() if k != "backend"}
106
+ draw_tool = DrawTool(
107
+ backend=_img_backend, backend_kwargs=_img_kwargs,
108
+ ) # file_store wired later by CognosAgent
109
+ # Image-edit (img2img + Kontext). Reads `image_edit` settings
110
+ # block; falls back to defaults baked into DiffusersImageEdit.
111
+ try:
112
+ from core.settings import Settings
113
+ _edit_cfg = Settings.load().get("image_edit", {}) or {}
114
+ except Exception:
115
+ _edit_cfg = {}
116
+ _edit_backend = _edit_cfg.get("backend", "diffusers")
117
+ _edit_kwargs = {k: v for k, v in _edit_cfg.items() if k != "backend"}
118
+ edit_image_tool = EditImageTool(
119
+ backend=_edit_backend, backend_kwargs=_edit_kwargs,
120
+ ) # file_store wired later by CognosAgent
121
+ storyboard_tool = StoryboardTool(
122
+ llm=self._llm,
123
+ ) # file_store + image backends wired later by CognosAgent
124
+ # LongCat Avatar (WAN-S2V talking-head video) — drives a local
125
+ # ComfyUI server. URL overridable via COGNOS_COMFYUI_URL.
126
+ longcat_avatar_tool = LongCatAvatarTool(
127
+ comfyui_url=os.environ.get(
128
+ "COGNOS_COMFYUI_URL", "http://127.0.0.1:8188",
129
+ ),
130
+ ) # file_store wired later by CognosAgent
131
+ artifact_tool = ArtifactTool() # file_store wired later by CognosAgent
132
+ speak_tool = SpeakTool() # file_store wired later by CognosAgent
133
+ for tool in [
134
+ ShellTool(),
135
+ PowerShellTool(),
136
+ FileReadTool(),
137
+ FileWriteTool(),
138
+ EditTool(),
139
+ FillInMiddleTool(),
140
+ GlobTool(),
141
+ GrepTool(),
142
+ NotebookEditTool(),
143
+ AskUserQuestionTool(),
144
+ PushNotificationTool(),
145
+ # Cron registry (read/create/delete)
146
+ CronListTool(),
147
+ CronCreateTool(),
148
+ CronDeleteTool(),
149
+ # Background task pool (TaskCreate needs the live agent —
150
+ # wired by CognosAgent.__init__ via set_agent())
151
+ TaskCreateTool(),
152
+ TaskListTool(),
153
+ TaskGetTool(),
154
+ TaskOutputTool(),
155
+ TaskStopTool(),
156
+ # MCP server visibility (needs the live agent)
157
+ MCPListServersTool(),
158
+ # Git worktrees
159
+ EnterWorktreeTool(),
160
+ ExitWorktreeTool(),
161
+ # Plan mode
162
+ EnterPlanModeTool(),
163
+ ExitPlanModeTool(),
164
+ FindAnywhereTool(),
165
+ SystemInfoTool(),
166
+ UpdateMemoryTool(),
167
+ CalculatorTool(),
168
+ DateTimeTool(),
169
+ SemanticSearchTool(),
170
+ DescribeImageTool(),
171
+ TranscribeAudioTool(),
172
+ HttpRequestTool(),
173
+ SandboxTool(),
174
+ AgenticTool(),
175
+ AgenticBrowseTool(),
176
+ OpenAPITool(),
177
+ LoadSkillTool(),
178
+ RescanSkillsTool(),
179
+ CognosCardTool(),
180
+ WebSearchTool(),
181
+ WebFetchTool(),
182
+ PythonExecTool(),
183
+ RespondTool(),
184
+ think_tool,
185
+ agent_tool,
186
+ draw_tool,
187
+ edit_image_tool,
188
+ longcat_avatar_tool,
189
+ storyboard_tool,
190
+ artifact_tool,
191
+ speak_tool,
192
+ ]:
193
+ self.register_tool(tool)
194
+
195
+ # Forge feature-CRUD tools — let the agent manage its own
196
+ # backlog when the orchestrator binds a project context.
197
+ # Outside of a forge run they refuse politely.
198
+ try:
199
+ from execution.tools.forge_feature_tools import FORGE_FEATURE_TOOLS
200
+ for cls in FORGE_FEATURE_TOOLS:
201
+ self.register_tool(cls())
202
+ except Exception as e:
203
+ logger.warning(f"Forge tool registration failed: {e}")
204
+
205
+ def set_llm(self, llm) -> None:
206
+ """Set or update the LLM for tools that need it."""
207
+ self._llm = llm
208
+ think = self.get_tool("think")
209
+ if think and hasattr(think, "set_llm"):
210
+ think.set_llm(llm)
211
+
212
+ def register_tool(self, tool: BaseTool) -> None:
213
+ """Register a tool."""
214
+ self._tools[tool.name] = tool
215
+ logger.debug(f"Registered tool: {tool.name}")
216
+
217
+ def get_tool(self, name: str) -> BaseTool | None:
218
+ if name in self._tools:
219
+ return self._tools[name]
220
+ # Fall back to legacy aliases
221
+ alias = _LEGACY_ALIASES.get(name)
222
+ if alias and alias in self._tools:
223
+ return self._tools[alias]
224
+ return None
225
+
226
+ def list_tools(self) -> list[str]:
227
+ return list(self._tools.keys())
228
+
229
+ def tool_definitions(self) -> list[dict]:
230
+ """Get LiteLLM-compatible tool definitions for tool calling."""
231
+ return [tool.to_tool_definition() for tool in self._tools.values()]
232
+
233
+ def tool_descriptions(self) -> str:
234
+ """Get descriptions for LLM context (legacy/display use)."""
235
+ lines = []
236
+ for name, tool in self._tools.items():
237
+ params = ", ".join(f"{k}: {v}" for k, v in tool.parameters.items())
238
+ lines.append(f"- {name}: {tool.description} ({params})")
239
+ return "\n".join(lines)
240
+
241
+ async def execute_tool(self, name: str, args: dict) -> ToolResult:
242
+ """Execute a tool by name with raw args (for agentic loop).
243
+
244
+ Unlike execute_task(), this takes raw name+args without a Task object.
245
+ """
246
+ tool = self.get_tool(name)
247
+ if tool is None:
248
+ return ToolResult(
249
+ tool_name=name,
250
+ status=ToolResultStatus.ERROR,
251
+ error=f"Unknown tool: {name}",
252
+ )
253
+ # Plan-mode gate: refuse any tool that mutates state. The two
254
+ # exceptions are EnterPlanMode/ExitPlanMode themselves so the
255
+ # agent can always toggle out.
256
+ try:
257
+ from core.plan_mode import is_active as _plan_active
258
+ if (
259
+ _plan_active()
260
+ and getattr(tool, "mutates", False)
261
+ and name not in ("EnterPlanMode", "ExitPlanMode")
262
+ ):
263
+ return ToolResult(
264
+ tool_name=name,
265
+ status=ToolResultStatus.ERROR,
266
+ error=(
267
+ f"Plan mode is ON — `{name}` mutates state "
268
+ "and is blocked. Read-only tools still work. "
269
+ "Call ExitPlanMode to allow mutations."
270
+ ),
271
+ )
272
+ except Exception:
273
+ pass # plan_mode module missing → behave as if mode is off
274
+ try:
275
+ return await tool.execute(**args)
276
+ except Exception as e:
277
+ return ToolResult(
278
+ tool_name=name,
279
+ status=ToolResultStatus.ERROR,
280
+ error=str(e),
281
+ )
282
+
283
+ async def execute_task(self, task: Task) -> tuple[ToolResult, Episode]:
284
+ """Execute a single task and return result + episode record."""
285
+ task.attempts += 1
286
+ start = time.time()
287
+
288
+ # Normalize "null"/"none" strings to actual None
289
+ if task.tool_name and task.tool_name.lower() in ("null", "none", "reasoning"):
290
+ task.tool_name = None
291
+
292
+ if not task.tool_name:
293
+ # Pure reasoning task — no tool to execute
294
+ result = ToolResult(
295
+ tool_name="reasoning",
296
+ status=ToolResultStatus.SUCCESS,
297
+ output=task.description,
298
+ )
299
+ else:
300
+ tool = self.get_tool(task.tool_name)
301
+ if tool is None:
302
+ result = ToolResult(
303
+ tool_name=task.tool_name,
304
+ status=ToolResultStatus.ERROR,
305
+ error=f"Unknown tool: {task.tool_name}",
306
+ )
307
+ else:
308
+ try:
309
+ result = await tool.execute(**task.tool_args)
310
+ except Exception as e:
311
+ result = ToolResult(
312
+ tool_name=task.tool_name,
313
+ status=ToolResultStatus.ERROR,
314
+ error=str(e),
315
+ )
316
+
317
+ result.duration_ms = (time.time() - start) * 1000
318
+
319
+ episode = Episode(
320
+ goal_id=task.goal_id,
321
+ task_id=task.id,
322
+ action=task.description,
323
+ tool_name=task.tool_name,
324
+ tool_args=task.tool_args,
325
+ result=result,
326
+ )
327
+
328
+ logger.info(f"Executed task '{task.description}': {result.status.value} ({result.duration_ms:.0f}ms)")
329
+ return result, episode
execution/plugins.py ADDED
@@ -0,0 +1,108 @@
1
+ """Plugin loader — discover and register community tools from a directory.
2
+
3
+ Each plugin is a Python file in the plugins directory that exposes ONE of:
4
+
5
+ 1. A module-level variable `PLUGIN` assigned to a BaseTool subclass instance:
6
+ from execution.tools.base import BaseTool
7
+ class Hello(BaseTool):
8
+ ...
9
+ PLUGIN = Hello()
10
+
11
+ 2. A module-level `register(executor)` callable that registers tools itself:
12
+ def register(executor):
13
+ executor.register_tool(MyToolA())
14
+ executor.register_tool(MyToolB())
15
+
16
+ Plugins are discovered from `plugins/*.py` (skipping files whose names start
17
+ with `_`) and `plugins/*/plugin.py` for package-style plugins.
18
+
19
+ Failures in one plugin never prevent loading the others — they're logged
20
+ and skipped.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import importlib.util
26
+ import logging
27
+ import sys
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from execution.tools.base import BaseTool
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ def load_plugins(plugin_dir: Path, executor: Any) -> int:
37
+ """Discover and register every plugin in `plugin_dir`. Returns count registered."""
38
+ if not plugin_dir.exists():
39
+ return 0
40
+
41
+ count = 0
42
+ for path in _discover(plugin_dir):
43
+ try:
44
+ registered = _load_one(path, executor)
45
+ except Exception as e:
46
+ logger.warning(f"Plugin {path} failed to load: {e}")
47
+ continue
48
+ count += registered
49
+ return count
50
+
51
+
52
+ def _discover(plugin_dir: Path) -> list[Path]:
53
+ """Return every plugin entry point under `plugin_dir`.
54
+
55
+ - Top-level files: plugin_dir/*.py (skipping _-prefixed and __init__)
56
+ - Package style: plugin_dir/<name>/plugin.py
57
+ """
58
+ entries: list[Path] = []
59
+ for path in sorted(plugin_dir.glob("*.py")):
60
+ name = path.stem
61
+ if name.startswith("_"):
62
+ continue
63
+ entries.append(path)
64
+ for path in sorted(plugin_dir.glob("*/plugin.py")):
65
+ entries.append(path)
66
+ return entries
67
+
68
+
69
+ def _load_one(path: Path, executor: Any) -> int:
70
+ """Import `path` as a module and register whatever it exposes."""
71
+ module_name = f"cognos_plugin_{path.parent.name}_{path.stem}"
72
+ spec = importlib.util.spec_from_file_location(module_name, path)
73
+ if spec is None or spec.loader is None:
74
+ raise ImportError(f"Cannot load spec for {path}")
75
+ module = importlib.util.module_from_spec(spec)
76
+ sys.modules[module_name] = module
77
+ spec.loader.exec_module(module)
78
+
79
+ registered = 0
80
+
81
+ # Preferred: explicit register() callable
82
+ register_fn = getattr(module, "register", None)
83
+ if callable(register_fn):
84
+ before = len(executor.list_tools())
85
+ register_fn(executor)
86
+ registered = len(executor.list_tools()) - before
87
+ logger.info(f"Plugin {path.name}: register() added {registered} tool(s)")
88
+ return registered
89
+
90
+ # Alternative: a PLUGIN variable pointing to a BaseTool instance (or list)
91
+ plugin_obj = getattr(module, "PLUGIN", None)
92
+ if plugin_obj is None:
93
+ logger.warning(
94
+ f"Plugin {path.name} has neither PLUGIN nor register() — skipping"
95
+ )
96
+ return 0
97
+
98
+ candidates = plugin_obj if isinstance(plugin_obj, (list, tuple)) else [plugin_obj]
99
+ for obj in candidates:
100
+ if not isinstance(obj, BaseTool):
101
+ logger.warning(
102
+ f"Plugin {path.name} PLUGIN entry {obj!r} is not a BaseTool instance"
103
+ )
104
+ continue
105
+ executor.register_tool(obj)
106
+ registered += 1
107
+ logger.info(f"Plugin {path.name}: registered {registered} tool(s)")
108
+ return registered
File without changes
@@ -0,0 +1,107 @@
1
+ """Agent tool — the Respond-like primitive that lets the parent spawn subagents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from core.schemas import ToolResult
8
+ from execution.tools.base import BaseTool
9
+
10
+
11
+ class AgentTool(BaseTool):
12
+ name = "Agent"
13
+ description = (
14
+ "Spawn a subagent OR dispatch a message on the inter-agent bus.\n\n"
15
+ "Spawn mode (default): supply `task` (+ optional `subagent_type`, "
16
+ "`isolation`) to spawn a child agent and wait for its summary. "
17
+ "Available subagent_types: general-purpose, research, code-review, "
18
+ "executor_brain, critic_brain.\n\n"
19
+ "Bus mode: supply `recipient_agent_id` "
20
+ "(executor_brain_01 or critic_brain_02) plus `payload_context`. "
21
+ "Set `suspend_sender_state=true` to wait for the recipient's "
22
+ "reply; otherwise fire-and-forget."
23
+ )
24
+
25
+ @property
26
+ def input_schema(self) -> dict:
27
+ return {
28
+ "type": "object",
29
+ "properties": {
30
+ "task": {
31
+ "type": "string",
32
+ "description": "Spawn mode: what the subagent should accomplish. Be specific — the subagent does not see your context.",
33
+ },
34
+ "subagent_type": {
35
+ "type": "string",
36
+ "description": "Spawn mode preset",
37
+ "enum": ["general-purpose", "research", "code-review",
38
+ "executor_brain", "critic_brain"],
39
+ "default": "general-purpose",
40
+ },
41
+ "isolation": {
42
+ "type": "string",
43
+ "description": "Spawn mode: set 'worktree' for an isolated working copy. Best-effort.",
44
+ "enum": ["worktree"],
45
+ },
46
+ "recipient_agent_id": {
47
+ "type": "string",
48
+ "description": "Bus mode: which agent to message.",
49
+ "enum": ["executor_brain_01", "critic_brain_02"],
50
+ },
51
+ "payload_context": {
52
+ "type": "string",
53
+ "description": "Bus mode: message body delivered to the recipient.",
54
+ },
55
+ "suspend_sender_state": {
56
+ "type": "boolean",
57
+ "description": "Bus mode: if true, await the recipient's reply before returning.",
58
+ "default": False,
59
+ },
60
+ },
61
+ }
62
+
63
+ def __init__(self, manager=None):
64
+ self._manager = manager
65
+
66
+ def set_manager(self, manager) -> None:
67
+ self._manager = manager
68
+
69
+ async def execute(self, **kwargs: Any) -> ToolResult:
70
+ if self._manager is None:
71
+ return self._error("SubagentManager not configured")
72
+
73
+ recipient = kwargs.get("recipient_agent_id")
74
+ if recipient:
75
+ payload = kwargs.get("payload_context", "")
76
+ if not payload:
77
+ return self._error("payload_context required in bus mode")
78
+ suspend = bool(kwargs.get("suspend_sender_state", False))
79
+ try:
80
+ reply = await self._manager.dispatch(
81
+ sender_id="agent_tool",
82
+ recipient_id=recipient,
83
+ context=payload,
84
+ suspend_sender=suspend,
85
+ )
86
+ except Exception as e:
87
+ return self._error(f"Bus dispatch failed: {e}")
88
+ if suspend:
89
+ return self._success(str(reply) if reply is not None else "(no reply)")
90
+ return self._success(f"Message queued for {recipient}.")
91
+
92
+ task = kwargs.get("task", "")
93
+ subagent_type = kwargs.get("subagent_type", "general-purpose")
94
+ isolation = kwargs.get("isolation")
95
+
96
+ if not task:
97
+ return self._error("No task provided (and no recipient_agent_id for bus mode)")
98
+
99
+ run = await self._manager.run_one(
100
+ task, subagent_type=subagent_type, isolation=isolation,
101
+ )
102
+ if run.error:
103
+ return self._error(f"Subagent {run.id} failed: {run.error}")
104
+ result = run.result or "(no output)"
105
+ if run.worktree_path:
106
+ result += f"\n\n[worktree: {run.worktree_path} branch={run.worktree_branch}]"
107
+ return self._success(result)