gobby 0.2.5__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 (383) hide show
  1. gobby/__init__.py +3 -0
  2. gobby/adapters/__init__.py +30 -0
  3. gobby/adapters/base.py +93 -0
  4. gobby/adapters/claude_code.py +276 -0
  5. gobby/adapters/codex.py +1292 -0
  6. gobby/adapters/gemini.py +343 -0
  7. gobby/agents/__init__.py +37 -0
  8. gobby/agents/codex_session.py +120 -0
  9. gobby/agents/constants.py +112 -0
  10. gobby/agents/context.py +362 -0
  11. gobby/agents/definitions.py +133 -0
  12. gobby/agents/gemini_session.py +111 -0
  13. gobby/agents/registry.py +618 -0
  14. gobby/agents/runner.py +968 -0
  15. gobby/agents/session.py +259 -0
  16. gobby/agents/spawn.py +916 -0
  17. gobby/agents/spawners/__init__.py +77 -0
  18. gobby/agents/spawners/base.py +142 -0
  19. gobby/agents/spawners/cross_platform.py +266 -0
  20. gobby/agents/spawners/embedded.py +225 -0
  21. gobby/agents/spawners/headless.py +226 -0
  22. gobby/agents/spawners/linux.py +125 -0
  23. gobby/agents/spawners/macos.py +277 -0
  24. gobby/agents/spawners/windows.py +308 -0
  25. gobby/agents/tty_config.py +319 -0
  26. gobby/autonomous/__init__.py +32 -0
  27. gobby/autonomous/progress_tracker.py +447 -0
  28. gobby/autonomous/stop_registry.py +269 -0
  29. gobby/autonomous/stuck_detector.py +383 -0
  30. gobby/cli/__init__.py +67 -0
  31. gobby/cli/__main__.py +8 -0
  32. gobby/cli/agents.py +529 -0
  33. gobby/cli/artifacts.py +266 -0
  34. gobby/cli/daemon.py +329 -0
  35. gobby/cli/extensions.py +526 -0
  36. gobby/cli/github.py +263 -0
  37. gobby/cli/init.py +53 -0
  38. gobby/cli/install.py +614 -0
  39. gobby/cli/installers/__init__.py +37 -0
  40. gobby/cli/installers/antigravity.py +65 -0
  41. gobby/cli/installers/claude.py +363 -0
  42. gobby/cli/installers/codex.py +192 -0
  43. gobby/cli/installers/gemini.py +294 -0
  44. gobby/cli/installers/git_hooks.py +377 -0
  45. gobby/cli/installers/shared.py +737 -0
  46. gobby/cli/linear.py +250 -0
  47. gobby/cli/mcp.py +30 -0
  48. gobby/cli/mcp_proxy.py +698 -0
  49. gobby/cli/memory.py +304 -0
  50. gobby/cli/merge.py +384 -0
  51. gobby/cli/projects.py +79 -0
  52. gobby/cli/sessions.py +622 -0
  53. gobby/cli/tasks/__init__.py +30 -0
  54. gobby/cli/tasks/_utils.py +658 -0
  55. gobby/cli/tasks/ai.py +1025 -0
  56. gobby/cli/tasks/commits.py +169 -0
  57. gobby/cli/tasks/crud.py +685 -0
  58. gobby/cli/tasks/deps.py +135 -0
  59. gobby/cli/tasks/labels.py +63 -0
  60. gobby/cli/tasks/main.py +273 -0
  61. gobby/cli/tasks/search.py +178 -0
  62. gobby/cli/tui.py +34 -0
  63. gobby/cli/utils.py +513 -0
  64. gobby/cli/workflows.py +927 -0
  65. gobby/cli/worktrees.py +481 -0
  66. gobby/config/__init__.py +129 -0
  67. gobby/config/app.py +551 -0
  68. gobby/config/extensions.py +167 -0
  69. gobby/config/features.py +472 -0
  70. gobby/config/llm_providers.py +98 -0
  71. gobby/config/logging.py +66 -0
  72. gobby/config/mcp.py +346 -0
  73. gobby/config/persistence.py +247 -0
  74. gobby/config/servers.py +141 -0
  75. gobby/config/sessions.py +250 -0
  76. gobby/config/tasks.py +784 -0
  77. gobby/hooks/__init__.py +104 -0
  78. gobby/hooks/artifact_capture.py +213 -0
  79. gobby/hooks/broadcaster.py +243 -0
  80. gobby/hooks/event_handlers.py +723 -0
  81. gobby/hooks/events.py +218 -0
  82. gobby/hooks/git.py +169 -0
  83. gobby/hooks/health_monitor.py +171 -0
  84. gobby/hooks/hook_manager.py +856 -0
  85. gobby/hooks/hook_types.py +575 -0
  86. gobby/hooks/plugins.py +813 -0
  87. gobby/hooks/session_coordinator.py +396 -0
  88. gobby/hooks/verification_runner.py +268 -0
  89. gobby/hooks/webhooks.py +339 -0
  90. gobby/install/claude/commands/gobby/bug.md +51 -0
  91. gobby/install/claude/commands/gobby/chore.md +51 -0
  92. gobby/install/claude/commands/gobby/epic.md +52 -0
  93. gobby/install/claude/commands/gobby/eval.md +235 -0
  94. gobby/install/claude/commands/gobby/feat.md +49 -0
  95. gobby/install/claude/commands/gobby/nit.md +52 -0
  96. gobby/install/claude/commands/gobby/ref.md +52 -0
  97. gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
  98. gobby/install/claude/hooks/hook_dispatcher.py +364 -0
  99. gobby/install/claude/hooks/validate_settings.py +102 -0
  100. gobby/install/claude/hooks-template.json +118 -0
  101. gobby/install/codex/hooks/hook_dispatcher.py +153 -0
  102. gobby/install/codex/prompts/forget.md +7 -0
  103. gobby/install/codex/prompts/memories.md +7 -0
  104. gobby/install/codex/prompts/recall.md +7 -0
  105. gobby/install/codex/prompts/remember.md +13 -0
  106. gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
  107. gobby/install/gemini/hooks-template.json +138 -0
  108. gobby/install/shared/plugins/code_guardian.py +456 -0
  109. gobby/install/shared/plugins/example_notify.py +331 -0
  110. gobby/integrations/__init__.py +10 -0
  111. gobby/integrations/github.py +145 -0
  112. gobby/integrations/linear.py +145 -0
  113. gobby/llm/__init__.py +40 -0
  114. gobby/llm/base.py +120 -0
  115. gobby/llm/claude.py +578 -0
  116. gobby/llm/claude_executor.py +503 -0
  117. gobby/llm/codex.py +322 -0
  118. gobby/llm/codex_executor.py +513 -0
  119. gobby/llm/executor.py +316 -0
  120. gobby/llm/factory.py +34 -0
  121. gobby/llm/gemini.py +258 -0
  122. gobby/llm/gemini_executor.py +339 -0
  123. gobby/llm/litellm.py +287 -0
  124. gobby/llm/litellm_executor.py +303 -0
  125. gobby/llm/resolver.py +499 -0
  126. gobby/llm/service.py +236 -0
  127. gobby/mcp_proxy/__init__.py +29 -0
  128. gobby/mcp_proxy/actions.py +175 -0
  129. gobby/mcp_proxy/daemon_control.py +198 -0
  130. gobby/mcp_proxy/importer.py +436 -0
  131. gobby/mcp_proxy/lazy.py +325 -0
  132. gobby/mcp_proxy/manager.py +798 -0
  133. gobby/mcp_proxy/metrics.py +609 -0
  134. gobby/mcp_proxy/models.py +139 -0
  135. gobby/mcp_proxy/registries.py +215 -0
  136. gobby/mcp_proxy/schema_hash.py +381 -0
  137. gobby/mcp_proxy/semantic_search.py +706 -0
  138. gobby/mcp_proxy/server.py +549 -0
  139. gobby/mcp_proxy/services/__init__.py +0 -0
  140. gobby/mcp_proxy/services/fallback.py +306 -0
  141. gobby/mcp_proxy/services/recommendation.py +224 -0
  142. gobby/mcp_proxy/services/server_mgmt.py +214 -0
  143. gobby/mcp_proxy/services/system.py +72 -0
  144. gobby/mcp_proxy/services/tool_filter.py +231 -0
  145. gobby/mcp_proxy/services/tool_proxy.py +309 -0
  146. gobby/mcp_proxy/stdio.py +565 -0
  147. gobby/mcp_proxy/tools/__init__.py +27 -0
  148. gobby/mcp_proxy/tools/agents.py +1103 -0
  149. gobby/mcp_proxy/tools/artifacts.py +207 -0
  150. gobby/mcp_proxy/tools/hub.py +335 -0
  151. gobby/mcp_proxy/tools/internal.py +337 -0
  152. gobby/mcp_proxy/tools/memory.py +543 -0
  153. gobby/mcp_proxy/tools/merge.py +422 -0
  154. gobby/mcp_proxy/tools/metrics.py +283 -0
  155. gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
  156. gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
  157. gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
  158. gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
  159. gobby/mcp_proxy/tools/orchestration/review.py +736 -0
  160. gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
  161. gobby/mcp_proxy/tools/session_messages.py +1056 -0
  162. gobby/mcp_proxy/tools/task_dependencies.py +219 -0
  163. gobby/mcp_proxy/tools/task_expansion.py +591 -0
  164. gobby/mcp_proxy/tools/task_github.py +393 -0
  165. gobby/mcp_proxy/tools/task_linear.py +379 -0
  166. gobby/mcp_proxy/tools/task_orchestration.py +77 -0
  167. gobby/mcp_proxy/tools/task_readiness.py +522 -0
  168. gobby/mcp_proxy/tools/task_sync.py +351 -0
  169. gobby/mcp_proxy/tools/task_validation.py +843 -0
  170. gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
  171. gobby/mcp_proxy/tools/tasks/_context.py +112 -0
  172. gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
  173. gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
  174. gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
  175. gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
  176. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
  177. gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
  178. gobby/mcp_proxy/tools/tasks/_search.py +215 -0
  179. gobby/mcp_proxy/tools/tasks/_session.py +125 -0
  180. gobby/mcp_proxy/tools/workflows.py +973 -0
  181. gobby/mcp_proxy/tools/worktrees.py +1264 -0
  182. gobby/mcp_proxy/transports/__init__.py +0 -0
  183. gobby/mcp_proxy/transports/base.py +95 -0
  184. gobby/mcp_proxy/transports/factory.py +44 -0
  185. gobby/mcp_proxy/transports/http.py +139 -0
  186. gobby/mcp_proxy/transports/stdio.py +213 -0
  187. gobby/mcp_proxy/transports/websocket.py +136 -0
  188. gobby/memory/backends/__init__.py +116 -0
  189. gobby/memory/backends/mem0.py +408 -0
  190. gobby/memory/backends/memu.py +485 -0
  191. gobby/memory/backends/null.py +111 -0
  192. gobby/memory/backends/openmemory.py +537 -0
  193. gobby/memory/backends/sqlite.py +304 -0
  194. gobby/memory/context.py +87 -0
  195. gobby/memory/manager.py +1001 -0
  196. gobby/memory/protocol.py +451 -0
  197. gobby/memory/search/__init__.py +66 -0
  198. gobby/memory/search/text.py +127 -0
  199. gobby/memory/viz.py +258 -0
  200. gobby/prompts/__init__.py +13 -0
  201. gobby/prompts/defaults/expansion/system.md +119 -0
  202. gobby/prompts/defaults/expansion/user.md +48 -0
  203. gobby/prompts/defaults/external_validation/agent.md +72 -0
  204. gobby/prompts/defaults/external_validation/external.md +63 -0
  205. gobby/prompts/defaults/external_validation/spawn.md +83 -0
  206. gobby/prompts/defaults/external_validation/system.md +6 -0
  207. gobby/prompts/defaults/features/import_mcp.md +22 -0
  208. gobby/prompts/defaults/features/import_mcp_github.md +17 -0
  209. gobby/prompts/defaults/features/import_mcp_search.md +16 -0
  210. gobby/prompts/defaults/features/recommend_tools.md +32 -0
  211. gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
  212. gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
  213. gobby/prompts/defaults/features/server_description.md +20 -0
  214. gobby/prompts/defaults/features/server_description_system.md +6 -0
  215. gobby/prompts/defaults/features/task_description.md +31 -0
  216. gobby/prompts/defaults/features/task_description_system.md +6 -0
  217. gobby/prompts/defaults/features/tool_summary.md +17 -0
  218. gobby/prompts/defaults/features/tool_summary_system.md +6 -0
  219. gobby/prompts/defaults/research/step.md +58 -0
  220. gobby/prompts/defaults/validation/criteria.md +47 -0
  221. gobby/prompts/defaults/validation/validate.md +38 -0
  222. gobby/prompts/loader.py +346 -0
  223. gobby/prompts/models.py +113 -0
  224. gobby/py.typed +0 -0
  225. gobby/runner.py +488 -0
  226. gobby/search/__init__.py +23 -0
  227. gobby/search/protocol.py +104 -0
  228. gobby/search/tfidf.py +232 -0
  229. gobby/servers/__init__.py +7 -0
  230. gobby/servers/http.py +636 -0
  231. gobby/servers/models.py +31 -0
  232. gobby/servers/routes/__init__.py +23 -0
  233. gobby/servers/routes/admin.py +416 -0
  234. gobby/servers/routes/dependencies.py +118 -0
  235. gobby/servers/routes/mcp/__init__.py +24 -0
  236. gobby/servers/routes/mcp/hooks.py +135 -0
  237. gobby/servers/routes/mcp/plugins.py +121 -0
  238. gobby/servers/routes/mcp/tools.py +1337 -0
  239. gobby/servers/routes/mcp/webhooks.py +159 -0
  240. gobby/servers/routes/sessions.py +582 -0
  241. gobby/servers/websocket.py +766 -0
  242. gobby/sessions/__init__.py +13 -0
  243. gobby/sessions/analyzer.py +322 -0
  244. gobby/sessions/lifecycle.py +240 -0
  245. gobby/sessions/manager.py +563 -0
  246. gobby/sessions/processor.py +225 -0
  247. gobby/sessions/summary.py +532 -0
  248. gobby/sessions/transcripts/__init__.py +41 -0
  249. gobby/sessions/transcripts/base.py +125 -0
  250. gobby/sessions/transcripts/claude.py +386 -0
  251. gobby/sessions/transcripts/codex.py +143 -0
  252. gobby/sessions/transcripts/gemini.py +195 -0
  253. gobby/storage/__init__.py +21 -0
  254. gobby/storage/agents.py +409 -0
  255. gobby/storage/artifact_classifier.py +341 -0
  256. gobby/storage/artifacts.py +285 -0
  257. gobby/storage/compaction.py +67 -0
  258. gobby/storage/database.py +357 -0
  259. gobby/storage/inter_session_messages.py +194 -0
  260. gobby/storage/mcp.py +680 -0
  261. gobby/storage/memories.py +562 -0
  262. gobby/storage/merge_resolutions.py +550 -0
  263. gobby/storage/migrations.py +860 -0
  264. gobby/storage/migrations_legacy.py +1359 -0
  265. gobby/storage/projects.py +166 -0
  266. gobby/storage/session_messages.py +251 -0
  267. gobby/storage/session_tasks.py +97 -0
  268. gobby/storage/sessions.py +817 -0
  269. gobby/storage/task_dependencies.py +223 -0
  270. gobby/storage/tasks/__init__.py +42 -0
  271. gobby/storage/tasks/_aggregates.py +180 -0
  272. gobby/storage/tasks/_crud.py +449 -0
  273. gobby/storage/tasks/_id.py +104 -0
  274. gobby/storage/tasks/_lifecycle.py +311 -0
  275. gobby/storage/tasks/_manager.py +889 -0
  276. gobby/storage/tasks/_models.py +300 -0
  277. gobby/storage/tasks/_ordering.py +119 -0
  278. gobby/storage/tasks/_path_cache.py +110 -0
  279. gobby/storage/tasks/_queries.py +343 -0
  280. gobby/storage/tasks/_search.py +143 -0
  281. gobby/storage/workflow_audit.py +393 -0
  282. gobby/storage/worktrees.py +547 -0
  283. gobby/sync/__init__.py +29 -0
  284. gobby/sync/github.py +333 -0
  285. gobby/sync/linear.py +304 -0
  286. gobby/sync/memories.py +284 -0
  287. gobby/sync/tasks.py +641 -0
  288. gobby/tasks/__init__.py +8 -0
  289. gobby/tasks/build_verification.py +193 -0
  290. gobby/tasks/commits.py +633 -0
  291. gobby/tasks/context.py +747 -0
  292. gobby/tasks/criteria.py +342 -0
  293. gobby/tasks/enhanced_validator.py +226 -0
  294. gobby/tasks/escalation.py +263 -0
  295. gobby/tasks/expansion.py +626 -0
  296. gobby/tasks/external_validator.py +764 -0
  297. gobby/tasks/issue_extraction.py +171 -0
  298. gobby/tasks/prompts/expand.py +327 -0
  299. gobby/tasks/research.py +421 -0
  300. gobby/tasks/tdd.py +352 -0
  301. gobby/tasks/tree_builder.py +263 -0
  302. gobby/tasks/validation.py +712 -0
  303. gobby/tasks/validation_history.py +357 -0
  304. gobby/tasks/validation_models.py +89 -0
  305. gobby/tools/__init__.py +0 -0
  306. gobby/tools/summarizer.py +170 -0
  307. gobby/tui/__init__.py +5 -0
  308. gobby/tui/api_client.py +281 -0
  309. gobby/tui/app.py +327 -0
  310. gobby/tui/screens/__init__.py +25 -0
  311. gobby/tui/screens/agents.py +333 -0
  312. gobby/tui/screens/chat.py +450 -0
  313. gobby/tui/screens/dashboard.py +377 -0
  314. gobby/tui/screens/memory.py +305 -0
  315. gobby/tui/screens/metrics.py +231 -0
  316. gobby/tui/screens/orchestrator.py +904 -0
  317. gobby/tui/screens/sessions.py +412 -0
  318. gobby/tui/screens/tasks.py +442 -0
  319. gobby/tui/screens/workflows.py +289 -0
  320. gobby/tui/screens/worktrees.py +174 -0
  321. gobby/tui/widgets/__init__.py +21 -0
  322. gobby/tui/widgets/chat.py +210 -0
  323. gobby/tui/widgets/conductor.py +104 -0
  324. gobby/tui/widgets/menu.py +132 -0
  325. gobby/tui/widgets/message_panel.py +160 -0
  326. gobby/tui/widgets/review_gate.py +224 -0
  327. gobby/tui/widgets/task_tree.py +99 -0
  328. gobby/tui/widgets/token_budget.py +166 -0
  329. gobby/tui/ws_client.py +258 -0
  330. gobby/utils/__init__.py +3 -0
  331. gobby/utils/daemon_client.py +235 -0
  332. gobby/utils/git.py +222 -0
  333. gobby/utils/id.py +38 -0
  334. gobby/utils/json_helpers.py +161 -0
  335. gobby/utils/logging.py +376 -0
  336. gobby/utils/machine_id.py +135 -0
  337. gobby/utils/metrics.py +589 -0
  338. gobby/utils/project_context.py +182 -0
  339. gobby/utils/project_init.py +263 -0
  340. gobby/utils/status.py +256 -0
  341. gobby/utils/validation.py +80 -0
  342. gobby/utils/version.py +23 -0
  343. gobby/workflows/__init__.py +4 -0
  344. gobby/workflows/actions.py +1310 -0
  345. gobby/workflows/approval_flow.py +138 -0
  346. gobby/workflows/artifact_actions.py +103 -0
  347. gobby/workflows/audit_helpers.py +110 -0
  348. gobby/workflows/autonomous_actions.py +286 -0
  349. gobby/workflows/context_actions.py +394 -0
  350. gobby/workflows/definitions.py +130 -0
  351. gobby/workflows/detection_helpers.py +208 -0
  352. gobby/workflows/engine.py +485 -0
  353. gobby/workflows/evaluator.py +669 -0
  354. gobby/workflows/git_utils.py +96 -0
  355. gobby/workflows/hooks.py +169 -0
  356. gobby/workflows/lifecycle_evaluator.py +613 -0
  357. gobby/workflows/llm_actions.py +70 -0
  358. gobby/workflows/loader.py +333 -0
  359. gobby/workflows/mcp_actions.py +60 -0
  360. gobby/workflows/memory_actions.py +272 -0
  361. gobby/workflows/premature_stop.py +164 -0
  362. gobby/workflows/session_actions.py +139 -0
  363. gobby/workflows/state_actions.py +123 -0
  364. gobby/workflows/state_manager.py +104 -0
  365. gobby/workflows/stop_signal_actions.py +163 -0
  366. gobby/workflows/summary_actions.py +344 -0
  367. gobby/workflows/task_actions.py +249 -0
  368. gobby/workflows/task_enforcement_actions.py +901 -0
  369. gobby/workflows/templates.py +52 -0
  370. gobby/workflows/todo_actions.py +84 -0
  371. gobby/workflows/webhook.py +223 -0
  372. gobby/workflows/webhook_executor.py +399 -0
  373. gobby/worktrees/__init__.py +5 -0
  374. gobby/worktrees/git.py +690 -0
  375. gobby/worktrees/merge/__init__.py +20 -0
  376. gobby/worktrees/merge/conflict_parser.py +177 -0
  377. gobby/worktrees/merge/resolver.py +485 -0
  378. gobby-0.2.5.dist-info/METADATA +351 -0
  379. gobby-0.2.5.dist-info/RECORD +383 -0
  380. gobby-0.2.5.dist-info/WHEEL +5 -0
  381. gobby-0.2.5.dist-info/entry_points.txt +2 -0
  382. gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
  383. gobby-0.2.5.dist-info/top_level.txt +1 -0
gobby/agents/runner.py ADDED
@@ -0,0 +1,968 @@
1
+ """
2
+ Agent runner for orchestrating agent execution.
3
+
4
+ The AgentRunner coordinates:
5
+ - Creating child sessions for agents
6
+ - Tracking agent runs in the database
7
+ - Executing agents via LLM providers
8
+ - Handling tool calls via the MCP proxy
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import threading
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from gobby.agents.registry import RunningAgent
19
+ from gobby.agents.session import ChildSessionConfig, ChildSessionManager
20
+ from gobby.llm.executor import AgentExecutor, AgentResult, ToolHandler, ToolResult, ToolSchema
21
+ from gobby.storage.agents import AgentRun, LocalAgentRunManager
22
+ from gobby.storage.sessions import Session
23
+ from gobby.workflows.definitions import WorkflowDefinition, WorkflowState
24
+ from gobby.workflows.loader import WorkflowLoader
25
+ from gobby.workflows.state_manager import WorkflowStateManager
26
+
27
+ if TYPE_CHECKING:
28
+ from gobby.storage.database import DatabaseProtocol
29
+ from gobby.storage.sessions import LocalSessionManager
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ @dataclass
35
+ class AgentConfig:
36
+ """Configuration for running an agent."""
37
+
38
+ prompt: str
39
+ """The prompt/task for the agent to perform."""
40
+
41
+ # Required context - can be inferred from get_project_context() or passed explicitly
42
+ parent_session_id: str | None = None
43
+ """ID of the session spawning this agent. Inferred from context if not provided."""
44
+
45
+ project_id: str | None = None
46
+ """Project ID for the agent's session. Inferred from context if not provided."""
47
+
48
+ machine_id: str | None = None
49
+ """Machine identifier. Defaults to hostname if not provided."""
50
+
51
+ source: str = "claude"
52
+ """CLI source (claude, gemini, codex)."""
53
+
54
+ # New spec-aligned parameters
55
+ workflow: str | None = None
56
+ """Workflow name or path to execute."""
57
+
58
+ task: str | None = None
59
+ """Task ID or 'next' for auto-select."""
60
+
61
+ agent: str | None = None
62
+ """Named agent definition to use."""
63
+
64
+ lifecycle_variables: dict[str, Any] | None = None
65
+ """Lifecycle variables to override parent settings."""
66
+
67
+ default_variables: dict[str, Any] | None = None
68
+ """Default variables for the agent."""
69
+
70
+ session_context: str = "summary_markdown"
71
+ """Context source: summary_markdown, compact_markdown, session_id:<id>, transcript:<n>, file:<path>."""
72
+
73
+ mode: str = "in_process"
74
+ """Execution mode: in_process, terminal, embedded, headless."""
75
+
76
+ terminal: str = "auto"
77
+ """Terminal for terminal/embedded modes: auto, ghostty, iterm, etc."""
78
+
79
+ worktree_id: str | None = None
80
+ """Existing worktree to use for terminal mode."""
81
+
82
+ # Provider settings
83
+ provider: str = "claude"
84
+ """LLM provider to use."""
85
+
86
+ model: str | None = None
87
+ """Optional model override."""
88
+
89
+ # Execution limits
90
+ max_turns: int = 10
91
+ """Maximum number of turns."""
92
+
93
+ timeout: float = 120.0
94
+ """Execution timeout in seconds."""
95
+
96
+ # Legacy/internal fields (kept for compatibility)
97
+ workflow_name: str | None = None
98
+ """Deprecated: use 'workflow' instead. Kept for backward compatibility."""
99
+
100
+ system_prompt: str | None = None
101
+ """Optional system prompt override."""
102
+
103
+ tools: list[ToolSchema] | None = None
104
+ """Optional list of tools to provide."""
105
+
106
+ git_branch: str | None = None
107
+ """Git branch for the session."""
108
+
109
+ title: str | None = None
110
+ """Optional title for the agent session."""
111
+
112
+ project_path: str | None = None
113
+ """Project path for loading project-specific workflows."""
114
+
115
+ context_injected: bool = False
116
+ """Whether context was successfully injected into the prompt."""
117
+
118
+ def get_effective_workflow(self) -> str | None:
119
+ """Get the workflow name, preferring 'workflow' over legacy 'workflow_name'."""
120
+ return self.workflow or self.workflow_name
121
+
122
+
123
+ @dataclass
124
+ class AgentRunContext:
125
+ """
126
+ Runtime context for an agent execution.
127
+
128
+ Contains all the objects needed to execute an agent, created during
129
+ the prepare phase and used during execution.
130
+ """
131
+
132
+ session: Session | None = None
133
+ """Child session object created for this agent."""
134
+
135
+ run: AgentRun | None = None
136
+ """Agent run record from the database."""
137
+
138
+ workflow_state: WorkflowState | None = None
139
+ """Workflow state for the child session, if workflow specified."""
140
+
141
+ workflow_config: WorkflowDefinition | None = None
142
+ """Loaded workflow definition, if workflow_name was specified."""
143
+
144
+ # Convenience accessors for IDs
145
+ @property
146
+ def session_id(self) -> str | None:
147
+ """Get the child session ID."""
148
+ return self.session.id if self.session else None
149
+
150
+ @property
151
+ def run_id(self) -> str | None:
152
+ """Get the agent run ID."""
153
+ return self.run.id if self.run else None
154
+
155
+
156
+ class AgentRunner:
157
+ """
158
+ Orchestrates agent execution with session and run tracking.
159
+
160
+ The runner:
161
+ 1. Creates a child session for the agent
162
+ 2. Records the agent run in the database
163
+ 3. Executes the agent via the appropriate LLM provider
164
+ 4. Updates run status based on execution result
165
+
166
+ Example:
167
+ >>> runner = AgentRunner(db, session_storage, executor)
168
+ >>> result = await runner.run(AgentConfig(
169
+ ... prompt="Create a TODO list",
170
+ ... parent_session_id="sess-123",
171
+ ... project_id="proj-abc",
172
+ ... machine_id="machine-1",
173
+ ... source="claude",
174
+ ... ))
175
+ """
176
+
177
+ def __init__(
178
+ self,
179
+ db: DatabaseProtocol,
180
+ session_storage: LocalSessionManager,
181
+ executors: dict[str, AgentExecutor],
182
+ max_agent_depth: int = 1,
183
+ workflow_loader: WorkflowLoader | None = None,
184
+ ):
185
+ """
186
+ Initialize AgentRunner.
187
+
188
+ Args:
189
+ db: Database connection.
190
+ session_storage: Session storage manager.
191
+ executors: Map of provider name to executor instance.
192
+ max_agent_depth: Maximum nesting depth for agents.
193
+ workflow_loader: Optional WorkflowLoader for loading workflow definitions.
194
+ """
195
+ self.db = db
196
+ self._session_storage = session_storage
197
+ self._executors = executors
198
+ self._child_session_manager = ChildSessionManager(
199
+ session_storage,
200
+ max_agent_depth=max_agent_depth,
201
+ )
202
+ self._run_storage = LocalAgentRunManager(db)
203
+ self._workflow_loader = workflow_loader or WorkflowLoader()
204
+ from gobby.agents.definitions import AgentDefinitionLoader
205
+
206
+ self._agent_loader = AgentDefinitionLoader()
207
+ self._workflow_state_manager = WorkflowStateManager(db)
208
+
209
+ self.logger = logger
210
+
211
+ # Thread-safe in-memory tracking of running agents
212
+ self._running_agents: dict[str, RunningAgent] = {}
213
+ self._running_agents_lock = threading.Lock()
214
+
215
+ def get_executor(self, provider: str) -> AgentExecutor | None:
216
+ """Get executor for a provider."""
217
+ return self._executors.get(provider)
218
+
219
+ def register_executor(self, provider: str, executor: AgentExecutor) -> None:
220
+ """Register an executor for a provider."""
221
+ self._executors[provider] = executor
222
+ self.logger.info(f"Registered executor for provider: {provider}")
223
+
224
+ def can_spawn(self, parent_session_id: str) -> tuple[bool, str, int]:
225
+ """
226
+ Check if an agent can be spawned from the given session.
227
+
228
+ Args:
229
+ parent_session_id: The session attempting to spawn.
230
+
231
+ Returns:
232
+ Tuple of (can_spawn, reason, parent_depth).
233
+ The parent_depth is returned to avoid redundant depth lookups.
234
+ """
235
+ return self._child_session_manager.can_spawn_child(parent_session_id)
236
+
237
+ def prepare_run(self, config: AgentConfig) -> AgentRunContext | AgentResult:
238
+ """
239
+ Prepare for agent execution by creating database records.
240
+
241
+ Creates:
242
+ - Child session linked to parent
243
+ - Agent run record in database
244
+ - Workflow state (if workflow specified)
245
+
246
+ This method can be used separately for terminal mode, where we prepare
247
+ the database state, then spawn a terminal process that picks up from
248
+ the session via hooks.
249
+
250
+ Args:
251
+ config: Agent configuration.
252
+
253
+ Returns:
254
+ AgentRunContext on success, or AgentResult with error on failure.
255
+ """
256
+ # Validate required fields
257
+ if not config.parent_session_id:
258
+ return AgentResult(
259
+ output="",
260
+ status="error",
261
+ error="parent_session_id is required",
262
+ turns_used=0,
263
+ )
264
+ if not config.project_id:
265
+ return AgentResult(
266
+ output="",
267
+ status="error",
268
+ error="project_id is required",
269
+ turns_used=0,
270
+ )
271
+ if not config.machine_id:
272
+ return AgentResult(
273
+ output="",
274
+ status="error",
275
+ error="machine_id is required",
276
+ turns_used=0,
277
+ )
278
+
279
+ # Type narrowing for mypy - these are guaranteed non-None after validation above
280
+ parent_session_id: str = config.parent_session_id
281
+ project_id: str = config.project_id
282
+ machine_id: str = config.machine_id
283
+
284
+ # Check if we can spawn (also get parent_depth to avoid redundant lookups)
285
+ can_spawn, reason, _parent_depth = self.can_spawn(parent_session_id)
286
+ if not can_spawn:
287
+ self.logger.warning(f"Cannot spawn agent: {reason}")
288
+ return AgentResult(
289
+ output="",
290
+ status="error",
291
+ error=reason,
292
+ turns_used=0,
293
+ )
294
+
295
+ # Load agent definition if specified
296
+ if config.agent:
297
+ agent_def = self._agent_loader.load(config.agent)
298
+ if agent_def:
299
+ # Merge definition into config (config takes precedence if explicitly set?)
300
+ # Actually, definition provides defaults/overrides.
301
+ # Logic:
302
+ # 1. Use workflow from definition if not in config
303
+ # 2. Use model from definition if not in config
304
+ # 3. Merge lifecycle_variables
305
+
306
+ if not config.workflow:
307
+ config.workflow = agent_def.workflow
308
+
309
+ if not config.model:
310
+ config.model = agent_def.model
311
+
312
+ # Merge lifecycle variables (definition wins? or config? usually definition sets policy)
313
+ def_lifecycle = agent_def.lifecycle_variables or {}
314
+ config_lifecycle = config.lifecycle_variables or {}
315
+ # Config overrides definition? Or vice versa?
316
+ # The Plan says "Child session created with lifecycle_variables merged in"
317
+ # Let's say config overrides definition (standard)
318
+ config.lifecycle_variables = {**def_lifecycle, **config_lifecycle}
319
+
320
+ # Merge default variables
321
+ def_vars = agent_def.default_variables or {}
322
+ config_vars = config.default_variables or {}
323
+ config.default_variables = {**def_vars, **config_vars}
324
+
325
+ self.logger.info(f"Loaded agent definition '{config.agent}'")
326
+ else:
327
+ self.logger.warning(f"Agent definition '{config.agent}' not found")
328
+
329
+ # Get effective workflow name (prefers 'workflow' over legacy 'workflow_name')
330
+ effective_workflow = config.get_effective_workflow()
331
+
332
+ # Validate workflow BEFORE creating child session to avoid orphaned sessions
333
+ workflow_definition = None
334
+ if effective_workflow:
335
+ workflow_definition = self._workflow_loader.load_workflow(
336
+ effective_workflow,
337
+ project_path=config.project_path,
338
+ )
339
+ if workflow_definition:
340
+ # Reject lifecycle workflows - they run automatically via hooks
341
+ if workflow_definition.type == "lifecycle":
342
+ self.logger.error(
343
+ f"Cannot use lifecycle workflow '{effective_workflow}' for agent spawning"
344
+ )
345
+ return AgentResult(
346
+ output="",
347
+ status="error",
348
+ error=(
349
+ f"Cannot use lifecycle workflow '{effective_workflow}' for agent spawning. "
350
+ f"Lifecycle workflows run automatically on events. "
351
+ f"Use a step workflow like 'plan-execute' instead."
352
+ ),
353
+ turns_used=0,
354
+ )
355
+
356
+ # Create child session (now safe - workflow validated above)
357
+ try:
358
+ child_session = self._child_session_manager.create_child_session(
359
+ ChildSessionConfig(
360
+ parent_session_id=parent_session_id,
361
+ project_id=project_id,
362
+ machine_id=machine_id,
363
+ source=config.source,
364
+ workflow_name=effective_workflow,
365
+ title=config.title,
366
+ git_branch=config.git_branch,
367
+ lifecycle_variables=config.lifecycle_variables,
368
+ )
369
+ )
370
+ except ValueError as e:
371
+ self.logger.error(f"Failed to create child session: {e}")
372
+ return AgentResult(
373
+ output="",
374
+ status="error",
375
+ error=str(e),
376
+ turns_used=0,
377
+ )
378
+
379
+ # Initialize workflow state if workflow was loaded
380
+ workflow_state = None
381
+ if workflow_definition:
382
+ self.logger.info(
383
+ f"Loaded workflow '{effective_workflow}' for agent "
384
+ f"(type={workflow_definition.type})"
385
+ )
386
+
387
+ # Initialize workflow state for child session
388
+ initial_step = ""
389
+ if workflow_definition.steps:
390
+ initial_step = workflow_definition.steps[0].name
391
+
392
+ # Build initial variables with agent depth information
393
+ initial_variables = dict(workflow_definition.variables)
394
+ initial_variables["agent_depth"] = child_session.agent_depth
395
+ initial_variables["max_agent_depth"] = self._child_session_manager.max_agent_depth
396
+ initial_variables["can_spawn"] = (
397
+ child_session.agent_depth < self._child_session_manager.max_agent_depth
398
+ )
399
+ initial_variables["parent_session_id"] = parent_session_id
400
+
401
+ workflow_state = WorkflowState(
402
+ session_id=child_session.id,
403
+ workflow_name=effective_workflow,
404
+ step=initial_step,
405
+ variables=initial_variables,
406
+ )
407
+ self._workflow_state_manager.save_state(workflow_state)
408
+ self.logger.info(
409
+ f"Initialized workflow state for child session {child_session.id} "
410
+ f"(step={initial_step}, agent_depth={child_session.agent_depth})"
411
+ )
412
+ elif effective_workflow:
413
+ # workflow_definition is None but effective_workflow was specified
414
+ self.logger.warning(
415
+ f"Workflow '{effective_workflow}' not found, proceeding without workflow"
416
+ )
417
+
418
+ # Create agent run record
419
+ agent_run = self._run_storage.create(
420
+ parent_session_id=parent_session_id,
421
+ provider=config.provider,
422
+ prompt=config.prompt,
423
+ workflow_name=effective_workflow,
424
+ model=config.model,
425
+ child_session_id=child_session.id,
426
+ )
427
+
428
+ # Set terminal pickup metadata on child session for terminal mode
429
+ # This allows terminal-spawned agents to pick up their state via hooks
430
+ self._session_storage.update_terminal_pickup_metadata(
431
+ session_id=child_session.id,
432
+ workflow_name=effective_workflow,
433
+ agent_run_id=agent_run.id,
434
+ context_injected=config.context_injected,
435
+ original_prompt=config.prompt,
436
+ )
437
+
438
+ self.logger.info(
439
+ f"Prepared agent run {agent_run.id} "
440
+ f"(child_session={child_session.id}, provider={config.provider})"
441
+ )
442
+
443
+ # Convert child session (internal type) to Session for storage
444
+ # The create_child_session returns a Session dataclass
445
+ session_obj = child_session
446
+
447
+ return AgentRunContext(
448
+ session=session_obj,
449
+ run=agent_run,
450
+ workflow_state=workflow_state,
451
+ workflow_config=workflow_definition,
452
+ )
453
+
454
+ async def execute_run(
455
+ self,
456
+ context: AgentRunContext,
457
+ config: AgentConfig,
458
+ tool_handler: ToolHandler | None = None,
459
+ ) -> AgentResult:
460
+ """
461
+ Execute an agent using prepared context.
462
+
463
+ This method runs the agent loop using the context created by prepare_run().
464
+ For in_process mode only - terminal mode uses a different execution path.
465
+
466
+ Args:
467
+ context: Prepared run context from prepare_run().
468
+ config: Agent configuration.
469
+ tool_handler: Optional async callable for handling tool calls.
470
+
471
+ Returns:
472
+ AgentResult with execution outcome.
473
+ """
474
+ # Validate context
475
+ if not context.session or not context.run:
476
+ return AgentResult(
477
+ output="",
478
+ status="error",
479
+ error="Invalid context: missing session or run",
480
+ turns_used=0,
481
+ )
482
+
483
+ child_session = context.session
484
+ agent_run = context.run
485
+ workflow_definition = context.workflow_config
486
+
487
+ # Get executor for provider
488
+ executor = self.get_executor(config.provider)
489
+ if not executor:
490
+ error_msg = f"No executor registered for provider: {config.provider}"
491
+ self.logger.error(error_msg)
492
+ self._run_storage.fail(agent_run.id, error=error_msg)
493
+ return AgentResult(
494
+ output="",
495
+ status="error",
496
+ error=error_msg,
497
+ turns_used=0,
498
+ )
499
+
500
+ # Start the run
501
+ self._run_storage.start(agent_run.id)
502
+ self.logger.info(
503
+ f"Starting agent run {agent_run.id} "
504
+ f"(child_session={child_session.id}, provider={config.provider})"
505
+ )
506
+
507
+ # Track in memory for real-time status
508
+ # Note: parent_session_id is guaranteed non-None here because execute_run
509
+ # is only called after prepare_run validates it
510
+ self._track_running_agent(
511
+ run_id=agent_run.id,
512
+ parent_session_id=config.parent_session_id,
513
+ child_session_id=child_session.id,
514
+ provider=config.provider,
515
+ prompt=config.prompt,
516
+ mode=config.mode,
517
+ workflow_name=config.get_effective_workflow(),
518
+ model=config.model,
519
+ worktree_id=config.worktree_id,
520
+ )
521
+
522
+ # Set up tool handler with workflow filtering
523
+ async def default_tool_handler(tool_name: str, arguments: dict[str, Any]) -> ToolResult:
524
+ """Default tool handler that returns not implemented."""
525
+ return ToolResult(
526
+ tool_name=tool_name,
527
+ success=False,
528
+ error=f"Tool {tool_name} not implemented",
529
+ )
530
+
531
+ base_handler = tool_handler or default_tool_handler
532
+
533
+ # Create workflow-filtered handler if workflow is active
534
+ if workflow_definition:
535
+ handler = self._create_workflow_filtered_handler(
536
+ base_handler=base_handler,
537
+ session_id=child_session.id,
538
+ workflow_definition=workflow_definition,
539
+ )
540
+ else:
541
+ handler = base_handler
542
+
543
+ # Track tool calls to preserve partial progress info on exception
544
+ # Note: Each tool call within a turn counts separately. The executor's
545
+ # run() method handles turns - we only track tool calls for monitoring.
546
+ tool_calls_made = 0
547
+
548
+ async def tracking_handler(tool_name: str, arguments: dict[str, Any]) -> ToolResult:
549
+ nonlocal tool_calls_made
550
+ tool_calls_made += 1
551
+ # Update in-memory state for real-time monitoring
552
+ # Note: turns_used is tracked by the executor, not per tool call
553
+ self._update_running_agent(
554
+ agent_run.id,
555
+ tool_calls_count=tool_calls_made,
556
+ )
557
+ return await handler(tool_name, arguments)
558
+
559
+ # Execute the agent
560
+ try:
561
+ result = await executor.run(
562
+ prompt=config.prompt,
563
+ tools=config.tools or [],
564
+ tool_handler=tracking_handler,
565
+ system_prompt=config.system_prompt,
566
+ model=config.model,
567
+ max_turns=config.max_turns,
568
+ timeout=config.timeout,
569
+ )
570
+
571
+ # Update run based on result
572
+ if result.status == "success":
573
+ self._run_storage.complete(
574
+ agent_run.id,
575
+ result=result.output,
576
+ tool_calls_count=len(result.tool_calls),
577
+ turns_used=result.turns_used,
578
+ )
579
+ self.logger.info(
580
+ f"Agent run {agent_run.id} completed successfully "
581
+ f"({result.turns_used} turns, {len(result.tool_calls)} tool calls)"
582
+ )
583
+ elif result.status == "timeout":
584
+ self._run_storage.timeout(agent_run.id, turns_used=result.turns_used)
585
+ self.logger.warning(f"Agent run {agent_run.id} timed out")
586
+ elif result.status == "error":
587
+ self._run_storage.fail(
588
+ agent_run.id,
589
+ error=result.error or "Unknown error",
590
+ tool_calls_count=len(result.tool_calls),
591
+ turns_used=result.turns_used,
592
+ )
593
+ self.logger.error(f"Agent run {agent_run.id} failed: {result.error}")
594
+ else:
595
+ # Partial completion
596
+ self._run_storage.complete(
597
+ agent_run.id,
598
+ result=result.output,
599
+ tool_calls_count=len(result.tool_calls),
600
+ turns_used=result.turns_used,
601
+ )
602
+ self.logger.info(f"Agent run {agent_run.id} completed with status {result.status}")
603
+
604
+ # Update session status
605
+ if result.status in ("success", "partial"):
606
+ self._session_storage.update_status(child_session.id, "completed")
607
+ else:
608
+ self._session_storage.update_status(child_session.id, "failed")
609
+
610
+ # Remove from in-memory tracking
611
+ self._untrack_running_agent(agent_run.id)
612
+
613
+ # Set run_id and child_session_id on the result so callers don't need to call list_runs()
614
+ result.run_id = agent_run.id
615
+ result.child_session_id = child_session.id
616
+
617
+ return result
618
+
619
+ except Exception as e:
620
+ self.logger.error(f"Agent execution failed: {e}", exc_info=True)
621
+ # On exception, we don't know the actual turns used by the executor,
622
+ # so we pass 0. tool_calls_made is the count we tracked.
623
+ self._run_storage.fail(
624
+ agent_run.id,
625
+ error=str(e),
626
+ tool_calls_count=tool_calls_made,
627
+ turns_used=0,
628
+ )
629
+ self._session_storage.update_status(child_session.id, "failed")
630
+ # Remove from in-memory tracking
631
+ self._untrack_running_agent(agent_run.id)
632
+ return AgentResult(
633
+ output="",
634
+ status="error",
635
+ error=str(e),
636
+ turns_used=0,
637
+ )
638
+
639
+ async def run(
640
+ self,
641
+ config: AgentConfig,
642
+ tool_handler: ToolHandler | None = None,
643
+ ) -> AgentResult:
644
+ """
645
+ Run an agent with the given configuration.
646
+
647
+ This is the main entry point that combines prepare_run() and execute_run()
648
+ for in-process agent execution.
649
+
650
+ Args:
651
+ config: Agent configuration.
652
+ tool_handler: Optional async callable for handling tool calls.
653
+ If not provided, uses a default no-op handler.
654
+
655
+ Returns:
656
+ AgentResult with execution outcome.
657
+ """
658
+ # Prepare the run (create session, run record, workflow state)
659
+ result = self.prepare_run(config)
660
+
661
+ # If prepare_run returned an error, return it
662
+ if isinstance(result, AgentResult):
663
+ return result
664
+
665
+ # Execute the run with the prepared context
666
+ context = result
667
+ return await self.execute_run(context, config, tool_handler)
668
+
669
+ def get_run(self, run_id: str) -> Any | None:
670
+ """Get an agent run by ID."""
671
+ return self._run_storage.get(run_id)
672
+
673
+ def list_runs(
674
+ self,
675
+ parent_session_id: str,
676
+ status: str | None = None,
677
+ limit: int = 100,
678
+ ) -> list[Any]:
679
+ """List agent runs for a session."""
680
+ return self._run_storage.list_by_session(
681
+ parent_session_id,
682
+ status=status, # type: ignore
683
+ limit=limit,
684
+ )
685
+
686
+ def cancel_run(self, run_id: str) -> bool:
687
+ """Cancel a running agent."""
688
+ run = self._run_storage.get(run_id)
689
+ if not run:
690
+ return False
691
+ if run.status != "running":
692
+ return False
693
+
694
+ self._run_storage.cancel(run_id)
695
+
696
+ # Also mark session as cancelled
697
+ if run.child_session_id:
698
+ self._session_storage.update_status(run.child_session_id, "cancelled")
699
+
700
+ self.logger.info(f"Cancelled agent run {run_id}")
701
+
702
+ # Remove from in-memory tracking
703
+ with self._running_agents_lock:
704
+ self._running_agents.pop(run_id, None)
705
+
706
+ return True
707
+
708
+ # -------------------------------------------------------------------------
709
+ # In-memory Running Agents Management
710
+ # -------------------------------------------------------------------------
711
+
712
+ def _track_running_agent(
713
+ self,
714
+ run_id: str,
715
+ parent_session_id: str | None,
716
+ child_session_id: str,
717
+ provider: str,
718
+ prompt: str,
719
+ mode: str = "in_process",
720
+ workflow_name: str | None = None,
721
+ model: str | None = None,
722
+ worktree_id: str | None = None,
723
+ pid: int | None = None,
724
+ terminal_type: str | None = None,
725
+ master_fd: int | None = None,
726
+ ) -> RunningAgent:
727
+ """
728
+ Add an agent to the in-memory running agents dict.
729
+
730
+ Thread-safe operation using a lock.
731
+
732
+ Args:
733
+ run_id: The agent run ID.
734
+ parent_session_id: Session that spawned this agent.
735
+ child_session_id: Child session created for this agent.
736
+ provider: LLM provider.
737
+ prompt: The task prompt (not stored, kept for API compatibility).
738
+ mode: Execution mode.
739
+ workflow_name: Workflow being executed.
740
+ model: Model override (not stored, kept for API compatibility).
741
+ worktree_id: Worktree being used.
742
+ pid: Process ID for terminal/headless mode.
743
+ terminal_type: Terminal type for terminal mode.
744
+ master_fd: PTY master file descriptor for embedded mode.
745
+
746
+ Returns:
747
+ The created RunningAgent instance.
748
+ """
749
+ # Note: The registry's RunningAgent uses 'session_id' for child session
750
+ # and doesn't store prompt/model (those are in the database AgentRun record)
751
+ _ = prompt # Kept for API compatibility, stored in AgentRun
752
+ _ = model # Kept for API compatibility, stored in AgentRun
753
+ running_agent = RunningAgent(
754
+ run_id=run_id,
755
+ session_id=child_session_id,
756
+ parent_session_id=parent_session_id if parent_session_id else "",
757
+ mode=mode,
758
+ provider=provider,
759
+ workflow_name=workflow_name,
760
+ worktree_id=worktree_id,
761
+ pid=pid,
762
+ terminal_type=terminal_type,
763
+ master_fd=master_fd,
764
+ )
765
+
766
+ with self._running_agents_lock:
767
+ self._running_agents[run_id] = running_agent
768
+
769
+ self.logger.debug(f"Tracking running agent {run_id} (mode={mode})")
770
+ return running_agent
771
+
772
+ def _untrack_running_agent(self, run_id: str) -> RunningAgent | None:
773
+ """
774
+ Remove an agent from the in-memory running agents dict.
775
+
776
+ Thread-safe operation using a lock.
777
+
778
+ Args:
779
+ run_id: The agent run ID to remove.
780
+
781
+ Returns:
782
+ The removed RunningAgent, or None if not found.
783
+ """
784
+ with self._running_agents_lock:
785
+ agent = self._running_agents.pop(run_id, None)
786
+
787
+ if agent:
788
+ self.logger.debug(f"Untracked running agent {run_id}")
789
+ return agent
790
+
791
+ def _update_running_agent(
792
+ self,
793
+ run_id: str,
794
+ turns_used: int | None = None,
795
+ tool_calls_count: int | None = None,
796
+ ) -> RunningAgent | None:
797
+ """
798
+ Update in-memory state for a running agent.
799
+
800
+ Thread-safe operation using a lock.
801
+
802
+ Note: The registry's RunningAgent is lightweight and doesn't track
803
+ turns_used, tool_calls_count, or last_activity. These are tracked
804
+ in the database AgentRun record instead. This method verifies the
805
+ agent exists but doesn't modify it.
806
+
807
+ Args:
808
+ run_id: The agent run ID.
809
+ turns_used: Updated turns count (logged but not stored in-memory).
810
+ tool_calls_count: Updated tool calls count (logged but not stored in-memory).
811
+
812
+ Returns:
813
+ The RunningAgent if found, None otherwise.
814
+ """
815
+ _ = turns_used # Tracked in database AgentRun record
816
+ _ = tool_calls_count # Tracked in database AgentRun record
817
+ with self._running_agents_lock:
818
+ agent = self._running_agents.get(run_id)
819
+
820
+ return agent
821
+
822
+ def get_running_agent(self, run_id: str) -> RunningAgent | None:
823
+ """
824
+ Get a running agent by ID.
825
+
826
+ Thread-safe operation using a lock.
827
+
828
+ Args:
829
+ run_id: The agent run ID.
830
+
831
+ Returns:
832
+ The RunningAgent if found and running, None otherwise.
833
+ """
834
+ with self._running_agents_lock:
835
+ return self._running_agents.get(run_id)
836
+
837
+ def get_running_agents(
838
+ self,
839
+ parent_session_id: str | None = None,
840
+ ) -> list[RunningAgent]:
841
+ """
842
+ Get all running agents, optionally filtered by parent session.
843
+
844
+ Thread-safe operation using a lock.
845
+
846
+ Args:
847
+ parent_session_id: Optional filter by parent session.
848
+
849
+ Returns:
850
+ List of running agents.
851
+ """
852
+ with self._running_agents_lock:
853
+ agents = list(self._running_agents.values())
854
+
855
+ if parent_session_id:
856
+ agents = [a for a in agents if a.parent_session_id == parent_session_id]
857
+
858
+ return agents
859
+
860
+ def get_running_agents_count(self) -> int:
861
+ """
862
+ Get count of running agents.
863
+
864
+ Thread-safe operation using a lock.
865
+
866
+ Returns:
867
+ Number of running agents.
868
+ """
869
+ with self._running_agents_lock:
870
+ return len(self._running_agents)
871
+
872
+ def is_agent_running(self, run_id: str) -> bool:
873
+ """
874
+ Check if an agent is currently running.
875
+
876
+ Thread-safe operation using a lock.
877
+
878
+ Args:
879
+ run_id: The agent run ID.
880
+
881
+ Returns:
882
+ True if the agent is in the running dict.
883
+ """
884
+ with self._running_agents_lock:
885
+ return run_id in self._running_agents
886
+
887
+ def _create_workflow_filtered_handler(
888
+ self,
889
+ base_handler: ToolHandler,
890
+ session_id: str,
891
+ workflow_definition: WorkflowDefinition,
892
+ ) -> ToolHandler:
893
+ """
894
+ Create a tool handler that enforces workflow tool restrictions.
895
+
896
+ Args:
897
+ base_handler: The underlying tool handler to call for allowed tools.
898
+ session_id: Session ID for looking up workflow state.
899
+ workflow_definition: The workflow definition with step restrictions.
900
+
901
+ Returns:
902
+ An async callable that filters tools based on workflow state.
903
+ """
904
+
905
+ async def filtered_handler(tool_name: str, arguments: dict[str, Any]) -> ToolResult:
906
+ # Get current workflow state
907
+ state = self._workflow_state_manager.get_state(session_id)
908
+ if not state:
909
+ # No state - just pass through
910
+ return await base_handler(tool_name, arguments)
911
+
912
+ # Get current step
913
+ current_step = workflow_definition.get_step(state.step)
914
+ if not current_step:
915
+ # No step defined - pass through
916
+ return await base_handler(tool_name, arguments)
917
+
918
+ # Check blocked_tools first (explicit deny)
919
+ if tool_name in current_step.blocked_tools:
920
+ self.logger.warning(f"Tool '{tool_name}' blocked by workflow step '{state.step}'")
921
+ return ToolResult(
922
+ tool_name=tool_name,
923
+ success=False,
924
+ error=f"Tool '{tool_name}' is blocked in workflow step '{state.step}'",
925
+ )
926
+
927
+ # Check allowed_tools (if not "all")
928
+ if current_step.allowed_tools != "all":
929
+ if tool_name not in current_step.allowed_tools:
930
+ self.logger.warning(
931
+ f"Tool '{tool_name}' not allowed in workflow step '{state.step}'"
932
+ )
933
+ return ToolResult(
934
+ tool_name=tool_name,
935
+ success=False,
936
+ error=(
937
+ f"Tool '{tool_name}' is not allowed in workflow step "
938
+ f"'{state.step}'. Allowed tools: {current_step.allowed_tools}"
939
+ ),
940
+ )
941
+
942
+ # Handle 'complete' tool as workflow exit condition
943
+ if tool_name == "complete":
944
+ result_message = arguments.get("result", "Task completed")
945
+ self.logger.info(
946
+ f"Agent called 'complete' tool - workflow exit condition met "
947
+ f"(session={session_id}, step={state.step})"
948
+ )
949
+
950
+ # Update workflow state to indicate completion
951
+ state.variables["workflow_completed"] = True
952
+ state.variables["completion_result"] = result_message
953
+ self._workflow_state_manager.save_state(state)
954
+
955
+ return ToolResult(
956
+ tool_name=tool_name,
957
+ success=True,
958
+ result={
959
+ "status": "completed",
960
+ "message": result_message,
961
+ "step": state.step,
962
+ },
963
+ )
964
+
965
+ # Tool is allowed - pass through to base handler
966
+ return await base_handler(tool_name, arguments)
967
+
968
+ return filtered_handler