vidbyte-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 (269) hide show
  1. vidbyte_cli/__init__.py +11 -0
  2. vidbyte_cli/__main__.py +6 -0
  3. vidbyte_cli/cli.py +18 -0
  4. vidbyte_cli/commands/__init__.py +86 -0
  5. vidbyte_cli/commands/agent_options.py +53 -0
  6. vidbyte_cli/commands/agents/__init__.py +31 -0
  7. vidbyte_cli/commands/agents/suggestion/__init__.py +38 -0
  8. vidbyte_cli/commands/agents/suggestion/prompts/__init__.py +3 -0
  9. vidbyte_cli/commands/agents/suggestion/prompts/all_categories.md +5 -0
  10. vidbyte_cli/commands/agents/suggestion/prompts/approaches.md +100 -0
  11. vidbyte_cli/commands/agents/suggestion/prompts/avoid.md +100 -0
  12. vidbyte_cli/commands/agents/suggestion/prompts/blockers.md +100 -0
  13. vidbyte_cli/commands/agents/suggestion/prompts/capability.md +100 -0
  14. vidbyte_cli/commands/agents/suggestion/prompts/categories.md +5 -0
  15. vidbyte_cli/commands/agents/suggestion/prompts/category.md +5 -0
  16. vidbyte_cli/commands/agents/suggestion/prompts/completed.md +100 -0
  17. vidbyte_cli/commands/agents/suggestion/prompts/constraint.md +100 -0
  18. vidbyte_cli/commands/agents/suggestion/prompts/context.md +100 -0
  19. vidbyte_cli/commands/agents/suggestion/prompts/count.md +5 -0
  20. vidbyte_cli/commands/agents/suggestion/prompts/critic_model.md +100 -0
  21. vidbyte_cli/commands/agents/suggestion/prompts/decision.md +100 -0
  22. vidbyte_cli/commands/agents/suggestion/prompts/dry_run.md +5 -0
  23. vidbyte_cli/commands/agents/suggestion/prompts/extra_compute.md +5 -0
  24. vidbyte_cli/commands/agents/suggestion/prompts/feedback_accept.md +7 -0
  25. vidbyte_cli/commands/agents/suggestion/prompts/feedback_group.md +7 -0
  26. vidbyte_cli/commands/agents/suggestion/prompts/feedback_project.md +100 -0
  27. vidbyte_cli/commands/agents/suggestion/prompts/feedback_reason.md +98 -0
  28. vidbyte_cli/commands/agents/suggestion/prompts/feedback_reject.md +7 -0
  29. vidbyte_cli/commands/agents/suggestion/prompts/feedback_suggestion.md +101 -0
  30. vidbyte_cli/commands/agents/suggestion/prompts/files.md +100 -0
  31. vidbyte_cli/commands/agents/suggestion/prompts/forbidden.md +100 -0
  32. vidbyte_cli/commands/agents/suggestion/prompts/goal.md +100 -0
  33. vidbyte_cli/commands/agents/suggestion/prompts/handoff.md +7 -0
  34. vidbyte_cli/commands/agents/suggestion/prompts/horizon.md +5 -0
  35. vidbyte_cli/commands/agents/suggestion/prompts/hypotheses.md +100 -0
  36. vidbyte_cli/commands/agents/suggestion/prompts/idea.md +100 -0
  37. vidbyte_cli/commands/agents/suggestion/prompts/in_progress.md +100 -0
  38. vidbyte_cli/commands/agents/suggestion/prompts/input.md +100 -0
  39. vidbyte_cli/commands/agents/suggestion/prompts/library.py +29 -0
  40. vidbyte_cli/commands/agents/suggestion/prompts/max_messages.md +5 -0
  41. vidbyte_cli/commands/agents/suggestion/prompts/max_output_tokens.md +5 -0
  42. vidbyte_cli/commands/agents/suggestion/prompts/max_total_tokens.md +5 -0
  43. vidbyte_cli/commands/agents/suggestion/prompts/mistakes.md +100 -0
  44. vidbyte_cli/commands/agents/suggestion/prompts/outcomes.md +100 -0
  45. vidbyte_cli/commands/agents/suggestion/prompts/project.md +100 -0
  46. vidbyte_cli/commands/agents/suggestion/prompts/project_create.md +7 -0
  47. vidbyte_cli/commands/agents/suggestion/prompts/project_description.md +98 -0
  48. vidbyte_cli/commands/agents/suggestion/prompts/project_group.md +7 -0
  49. vidbyte_cli/commands/agents/suggestion/prompts/project_key.md +96 -0
  50. vidbyte_cli/commands/agents/suggestion/prompts/project_list.md +7 -0
  51. vidbyte_cli/commands/agents/suggestion/prompts/project_title.md +99 -0
  52. vidbyte_cli/commands/agents/suggestion/prompts/provider.md +5 -0
  53. vidbyte_cli/commands/agents/suggestion/prompts/question.md +100 -0
  54. vidbyte_cli/commands/agents/suggestion/prompts/risks.md +100 -0
  55. vidbyte_cli/commands/agents/suggestion/prompts/rounds.md +5 -0
  56. vidbyte_cli/commands/agents/suggestion/prompts/run.md +5 -0
  57. vidbyte_cli/commands/agents/suggestion/prompts/success.md +100 -0
  58. vidbyte_cli/commands/agents/suggestion/prompts/timeout.md +5 -0
  59. vidbyte_cli/commands/agents/suggestion/prompts/trajectory.md +100 -0
  60. vidbyte_cli/commands/agents/suggestion/prompts/view.md +5 -0
  61. vidbyte_cli/commands/agents/suggestion/prompts/view_all.md +5 -0
  62. vidbyte_cli/commands/agents/suggestion/render.py +77 -0
  63. vidbyte_cli/commands/agents/suggestion/request_builder.py +240 -0
  64. vidbyte_cli/commands/agents/suggestion/suggest.py +154 -0
  65. vidbyte_cli/commands/agents/suggestion/suggestion_categories.py +77 -0
  66. vidbyte_cli/commands/agents/suggestion/suggestion_feedback.py +85 -0
  67. vidbyte_cli/commands/agents/suggestion/suggestion_handoff.py +67 -0
  68. vidbyte_cli/commands/agents/suggestion/suggestion_projects.py +95 -0
  69. vidbyte_cli/commands/auth/__init__.py +0 -0
  70. vidbyte_cli/commands/auth/login.py +100 -0
  71. vidbyte_cli/commands/auth/logout.py +41 -0
  72. vidbyte_cli/commands/auth/whoami.py +51 -0
  73. vidbyte_cli/commands/billing/__init__.py +20 -0
  74. vidbyte_cli/commands/billing/prompts/__init__.py +1 -0
  75. vidbyte_cli/commands/billing/prompts/confirm.md +16 -0
  76. vidbyte_cli/commands/billing/prompts/group.md +13 -0
  77. vidbyte_cli/commands/billing/prompts/idempotency_key.md +16 -0
  78. vidbyte_cli/commands/billing/prompts/library.py +18 -0
  79. vidbyte_cli/commands/billing/prompts/top_up.md +16 -0
  80. vidbyte_cli/commands/billing/top_up.py +65 -0
  81. vidbyte_cli/commands/config/__init__.py +0 -0
  82. vidbyte_cli/commands/config/get.py +45 -0
  83. vidbyte_cli/commands/config/set.py +39 -0
  84. vidbyte_cli/commands/provider/__init__.py +9 -0
  85. vidbyte_cli/commands/provider/login.py +102 -0
  86. vidbyte_cli/commands/provider/logout.py +45 -0
  87. vidbyte_cli/commands/provider/whoami.py +49 -0
  88. vidbyte_cli/commands/research/__init__.py +5 -0
  89. vidbyte_cli/commands/research/add.py +41 -0
  90. vidbyte_cli/commands/research/options.py +134 -0
  91. vidbyte_cli/commands/research/render.py +157 -0
  92. vidbyte_cli/commands/research/resume.py +37 -0
  93. vidbyte_cli/commands/research/start.py +37 -0
  94. vidbyte_cli/commands/research/status.py +32 -0
  95. vidbyte_cli/commands/research/thread.py +30 -0
  96. vidbyte_cli/commands/research/threads.py +35 -0
  97. vidbyte_cli/commands/research/watch.py +93 -0
  98. vidbyte_cli/commands/runtime/__init__.py +23 -0
  99. vidbyte_cli/commands/runtime/adversarial_team.py +41 -0
  100. vidbyte_cli/commands/runtime/doctor.py +44 -0
  101. vidbyte_cli/commands/runtime/list.py +47 -0
  102. vidbyte_cli/commands/runtime/persistence.py +141 -0
  103. vidbyte_cli/commands/runtime/same_host_ensemble.py +221 -0
  104. vidbyte_cli/commands/runtime/stages.py +553 -0
  105. vidbyte_cli/commands/runtime/task_board.py +1342 -0
  106. vidbyte_cli/commands/setup/__init__.py +0 -0
  107. vidbyte_cli/commands/setup/doctor.py +64 -0
  108. vidbyte_cli/commands/setup/prompts/__init__.py +1 -0
  109. vidbyte_cli/commands/setup/prompts/browser.md +7 -0
  110. vidbyte_cli/commands/setup/prompts/library.py +18 -0
  111. vidbyte_cli/commands/setup/prompts/star.md +13 -0
  112. vidbyte_cli/commands/setup/star.py +132 -0
  113. vidbyte_cli/lib/__init__.py +0 -0
  114. vidbyte_cli/lib/api/__init__.py +0 -0
  115. vidbyte_cli/lib/api/client.py +236 -0
  116. vidbyte_cli/lib/api/endpoints/__init__.py +0 -0
  117. vidbyte_cli/lib/api/endpoints/auth.py +24 -0
  118. vidbyte_cli/lib/api/endpoints/billing.py +36 -0
  119. vidbyte_cli/lib/api/endpoints/research.py +103 -0
  120. vidbyte_cli/lib/api/endpoints/runtime.py +113 -0
  121. vidbyte_cli/lib/api/problem.py +86 -0
  122. vidbyte_cli/lib/api/response.py +101 -0
  123. vidbyte_cli/lib/api/retry.py +116 -0
  124. vidbyte_cli/lib/api/runtime_payment.py +89 -0
  125. vidbyte_cli/lib/auth/__init__.py +16 -0
  126. vidbyte_cli/lib/auth/credentials.py +223 -0
  127. vidbyte_cli/lib/auth/input.py +59 -0
  128. vidbyte_cli/lib/auth/keyring_store.py +126 -0
  129. vidbyte_cli/lib/auth/provider_credentials.py +157 -0
  130. vidbyte_cli/lib/auth/provider_input.py +54 -0
  131. vidbyte_cli/lib/auth/provider_resolver.py +89 -0
  132. vidbyte_cli/lib/auth/provider_store.py +149 -0
  133. vidbyte_cli/lib/auth/provider_verifier.py +219 -0
  134. vidbyte_cli/lib/auth/resolver.py +63 -0
  135. vidbyte_cli/lib/auth/verifier.py +38 -0
  136. vidbyte_cli/lib/config/__init__.py +25 -0
  137. vidbyte_cli/lib/config/atomic.py +47 -0
  138. vidbyte_cli/lib/config/config.py +141 -0
  139. vidbyte_cli/lib/config/migration.py +81 -0
  140. vidbyte_cli/lib/config/models.py +145 -0
  141. vidbyte_cli/lib/config/paths.py +66 -0
  142. vidbyte_cli/lib/config/resolver.py +137 -0
  143. vidbyte_cli/lib/constants/__init__.py +1 -0
  144. vidbyte_cli/lib/constants/runtime.py +186 -0
  145. vidbyte_cli/lib/errors/__init__.py +12 -0
  146. vidbyte_cli/lib/errors/cli_error.py +86 -0
  147. vidbyte_cli/lib/errors/codes.py +43 -0
  148. vidbyte_cli/lib/errors/failures.py +2910 -0
  149. vidbyte_cli/lib/errors/handler.py +72 -0
  150. vidbyte_cli/lib/files/__init__.py +6 -0
  151. vidbyte_cli/lib/files/documents.py +94 -0
  152. vidbyte_cli/lib/files/store.py +156 -0
  153. vidbyte_cli/lib/io/__init__.py +16 -0
  154. vidbyte_cli/lib/io/attachments.py +129 -0
  155. vidbyte_cli/lib/io/codex_attachments.py +47 -0
  156. vidbyte_cli/lib/io/prompt.py +71 -0
  157. vidbyte_cli/lib/io/streams.py +37 -0
  158. vidbyte_cli/lib/io/terminal.py +66 -0
  159. vidbyte_cli/lib/output/__init__.py +7 -0
  160. vidbyte_cli/lib/output/formats.py +29 -0
  161. vidbyte_cli/lib/output/manager.py +95 -0
  162. vidbyte_cli/lib/output/models.py +49 -0
  163. vidbyte_cli/lib/runtime/__init__.py +9 -0
  164. vidbyte_cli/lib/runtime/application.py +134 -0
  165. vidbyte_cli/lib/runtime/context.py +250 -0
  166. vidbyte_cli/lib/runtime/options.py +159 -0
  167. vidbyte_cli/lib/runtime/version.py +30 -0
  168. vidbyte_cli/lib/runtime_primitives/__init__.py +28 -0
  169. vidbyte_cli/lib/runtime_primitives/executor.py +54 -0
  170. vidbyte_cli/lib/runtime_primitives/gate.py +105 -0
  171. vidbyte_cli/lib/runtime_primitives/hosts.py +45 -0
  172. vidbyte_cli/lib/runtime_primitives/planner.py +79 -0
  173. vidbyte_cli/lib/runtime_primitives/stages.py +338 -0
  174. vidbyte_cli/lib/runtime_primitives/task_board.py +982 -0
  175. vidbyte_cli/lib/runtime_primitives/task_board_checkpoints.py +473 -0
  176. vidbyte_cli/lib/runtime_primitives/task_board_system.md +33 -0
  177. vidbyte_cli/lib/runtime_primitives/task_board_tasks.py +111 -0
  178. vidbyte_cli/lib/runtime_primitives/verification.py +68 -0
  179. vidbyte_cli/services/__init__.py +5 -0
  180. vidbyte_cli/services/ensemble/__init__.py +7 -0
  181. vidbyte_cli/services/ensemble/prompts/__init__.py +6 -0
  182. vidbyte_cli/services/ensemble/prompts/implementer_system.md +546 -0
  183. vidbyte_cli/services/ensemble/prompts/implementer_turn.md +49 -0
  184. vidbyte_cli/services/ensemble/prompts/library.py +188 -0
  185. vidbyte_cli/services/ensemble/prompts/planner_system.md +728 -0
  186. vidbyte_cli/services/ensemble/prompts/planner_turn.md +6 -0
  187. vidbyte_cli/services/ensemble/prompts/role_system.md +104 -0
  188. vidbyte_cli/services/ensemble/prompts/role_turn.md +19 -0
  189. vidbyte_cli/services/ensemble/prompts/selector_final_turn.md +19 -0
  190. vidbyte_cli/services/ensemble/prompts/selector_round_turn.md +17 -0
  191. vidbyte_cli/services/ensemble/prompts/selector_system.md +89 -0
  192. vidbyte_cli/services/ensemble/runner.py +30 -0
  193. vidbyte_cli/services/ensemble/sdk.py +134 -0
  194. vidbyte_cli/services/ensemble/service.py +233 -0
  195. vidbyte_cli/services/ensemble/settings.py +60 -0
  196. vidbyte_cli/services/persistence/__init__.py +6 -0
  197. vidbyte_cli/services/persistence/prompts/__init__.py +6 -0
  198. vidbyte_cli/services/persistence/prompts/library.py +46 -0
  199. vidbyte_cli/services/persistence/prompts/persistence_system.md +1 -0
  200. vidbyte_cli/services/persistence/prompts/persistence_turn.md +3 -0
  201. vidbyte_cli/services/persistence/runner.py +35 -0
  202. vidbyte_cli/services/persistence/session.py +116 -0
  203. vidbyte_cli/services/suggestions/__init__.py +7 -0
  204. vidbyte_cli/services/suggestions/categories.py +270 -0
  205. vidbyte_cli/services/suggestions/context.py +222 -0
  206. vidbyte_cli/services/suggestions/extra_compute.py +53 -0
  207. vidbyte_cli/services/suggestions/handoff.py +173 -0
  208. vidbyte_cli/services/suggestions/message_tools.py +47 -0
  209. vidbyte_cli/services/suggestions/project.py +253 -0
  210. vidbyte_cli/services/suggestions/prompts/__init__.py +1 -0
  211. vidbyte_cli/services/suggestions/prompts/categories/__init__.py +3 -0
  212. vidbyte_cli/services/suggestions/prompts/categories/adjacent_opportunity.md +37 -0
  213. vidbyte_cli/services/suggestions/prompts/categories/alternative.md +38 -0
  214. vidbyte_cli/services/suggestions/prompts/categories/big_bets.md +37 -0
  215. vidbyte_cli/services/suggestions/prompts/categories/bottleneck.md +38 -0
  216. vidbyte_cli/services/suggestions/prompts/categories/business_growth.md +38 -0
  217. vidbyte_cli/services/suggestions/prompts/categories/completion.md +37 -0
  218. vidbyte_cli/services/suggestions/prompts/categories/continuation.md +37 -0
  219. vidbyte_cli/services/suggestions/prompts/categories/coordination.md +37 -0
  220. vidbyte_cli/services/suggestions/prompts/categories/creative_exploration.md +37 -0
  221. vidbyte_cli/services/suggestions/prompts/categories/cross_domain.md +37 -0
  222. vidbyte_cli/services/suggestions/prompts/categories/delegation.md +37 -0
  223. vidbyte_cli/services/suggestions/prompts/categories/experiment.md +37 -0
  224. vidbyte_cli/services/suggestions/prompts/categories/feedback.md +37 -0
  225. vidbyte_cli/services/suggestions/prompts/categories/goal_clarification.md +37 -0
  226. vidbyte_cli/services/suggestions/prompts/categories/immediate_next_steps.md +37 -0
  227. vidbyte_cli/services/suggestions/prompts/categories/investigation.md +37 -0
  228. vidbyte_cli/services/suggestions/prompts/categories/learning.md +37 -0
  229. vidbyte_cli/services/suggestions/prompts/categories/leverage.md +37 -0
  230. vidbyte_cli/services/suggestions/prompts/categories/long_term_directions.md +37 -0
  231. vidbyte_cli/services/suggestions/prompts/categories/preparation.md +37 -0
  232. vidbyte_cli/services/suggestions/prompts/categories/prerequisite.md +37 -0
  233. vidbyte_cli/services/suggestions/prompts/categories/prioritization.md +37 -0
  234. vidbyte_cli/services/suggestions/prompts/categories/product_experience.md +37 -0
  235. vidbyte_cli/services/suggestions/prompts/categories/quick_wins.md +37 -0
  236. vidbyte_cli/services/suggestions/prompts/categories/reframing.md +37 -0
  237. vidbyte_cli/services/suggestions/prompts/categories/risk_prevention.md +37 -0
  238. vidbyte_cli/services/suggestions/prompts/categories/simplification.md +37 -0
  239. vidbyte_cli/services/suggestions/prompts/categories/stop_or_defer.md +37 -0
  240. vidbyte_cli/services/suggestions/prompts/categories/strategy.md +37 -0
  241. vidbyte_cli/services/suggestions/prompts/categories/technical_possibilities.md +37 -0
  242. vidbyte_cli/services/suggestions/prompts/categories/verification.md +37 -0
  243. vidbyte_cli/services/suggestions/prompts/critic.md +209 -0
  244. vidbyte_cli/services/suggestions/prompts/critic_message.md +8 -0
  245. vidbyte_cli/services/suggestions/prompts/generator.md +64 -0
  246. vidbyte_cli/services/suggestions/prompts/library.py +72 -0
  247. vidbyte_cli/services/suggestions/prompts/message_generator_argument.md +1 -0
  248. vidbyte_cli/services/suggestions/prompts/message_generator_tool.md +1 -0
  249. vidbyte_cli/services/suggestions/prompts/message_parent_argument.md +1 -0
  250. vidbyte_cli/services/suggestions/prompts/message_parent_tool.md +1 -0
  251. vidbyte_cli/services/suggestions/prompts/message_receipt.md +1 -0
  252. vidbyte_cli/services/suggestions/prompts/revision.md +8 -0
  253. vidbyte_cli/services/suggestions/sdk.py +228 -0
  254. vidbyte_cli/services/suggestions/selection.py +78 -0
  255. vidbyte_cli/services/suggestions/service.py +540 -0
  256. vidbyte_cli/types/__init__.py +0 -0
  257. vidbyte_cli/types/api.py +101 -0
  258. vidbyte_cli/types/attachments.py +79 -0
  259. vidbyte_cli/types/ensemble.py +189 -0
  260. vidbyte_cli/types/provider.py +62 -0
  261. vidbyte_cli/types/research.py +260 -0
  262. vidbyte_cli/types/runtime.py +1098 -0
  263. vidbyte_cli/types/suggestions.py +518 -0
  264. vidbyte_cli-0.1.0.dist-info/METADATA +335 -0
  265. vidbyte_cli-0.1.0.dist-info/RECORD +269 -0
  266. vidbyte_cli-0.1.0.dist-info/WHEEL +5 -0
  267. vidbyte_cli-0.1.0.dist-info/entry_points.txt +2 -0
  268. vidbyte_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  269. vidbyte_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,982 @@
1
+ """Task-board execution over separate Codex agents with checkpointed handoffs.
2
+
3
+ The board owns ordering, context, and durability. The SDK owns each task turn. Raw prior
4
+ results never reach the next agent unless the handoff mode asks for them; a linear board
5
+ forwards windowed entries, a DAG board forwards only its direct dependencies' entries, and an
6
+ isolated board forwards nothing at all. Every finished step is durably recorded before the
7
+ next one starts, so a crash costs at most one step.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from collections.abc import Callable, Mapping
14
+ from importlib.resources import files
15
+ from pathlib import Path
16
+ from threading import Lock
17
+ from typing import TYPE_CHECKING, Annotated
18
+
19
+ from pydantic import Field
20
+
21
+ from ...types.runtime import RuntimeLaunchPlan as Plan
22
+ from ...types.runtime import (
23
+ TaskBoardCheckpoint,
24
+ TaskBoardCheckpointMode,
25
+ TaskBoardContextMode,
26
+ TaskBoardExecutionType,
27
+ TaskBoardHandoffMode,
28
+ TaskBoardPrefix,
29
+ TaskBoardResult,
30
+ TaskBoardRunControls,
31
+ TaskBoardSettings,
32
+ TaskBoardStepResult,
33
+ TaskBoardTaskOutcome,
34
+ TaskBoardTurn,
35
+ )
36
+ from ..constants.runtime import TaskBoardCodexConfig, TaskBoardLimit
37
+ from ..constants.runtime import TaskBoardProgress as Progress
38
+ from ..errors.cli_error import CliError
39
+ from ..errors.failures import (
40
+ LocalFileWriteFailed,
41
+ TaskBoardCheckpointMismatch,
42
+ TaskBoardDependencyInvalid,
43
+ TaskBoardHostFailed,
44
+ )
45
+ from .task_board_checkpoints import TaskBoardCheckpointer
46
+
47
+ if TYPE_CHECKING:
48
+ from vidbyte.agents.codex import CodexHarnessAgent
49
+ from vidbyte.agents.types import AgentMessage
50
+ from vidbyte.tools import FunctionTool
51
+
52
+
53
+ class TaskBoardSummarizer:
54
+ """Builds bounded summaries, handoff entries, and per-task prompts without network calls."""
55
+
56
+ def summarize(self, text: str, mode: str, limit: int) -> str:
57
+ # Shrinks one result deterministically so prompts stay bounded.
58
+ cleaned = text.strip()
59
+ if len(cleaned) <= limit:
60
+ return cleaned
61
+ if mode == "head-tail":
62
+ return self._head_tail(cleaned, limit)
63
+ return self._truncate_tail(cleaned, limit)
64
+
65
+ def handoff(self, settings: TaskBoardSettings, task: str, summary: str, result: str) -> str:
66
+ # One entry per finished task, shaped once at write time so every later reader — a
67
+ # live step, a resumed step, and a replay — sees byte-identical prior context.
68
+ if settings.handoff_mode is TaskBoardHandoffMode.TASK_AND_SUMMARY:
69
+ return f"Task: {task.strip()}\nResult: {summary}"
70
+ if settings.handoff_mode is TaskBoardHandoffMode.FULL_RESULT:
71
+ body = result.strip() or summary
72
+ return self.summarize(body, "truncate-tail", TaskBoardLimit.MAX_TASK_CHARS)
73
+ return summary
74
+
75
+ def render_context(self, entries: tuple[tuple[int, str], ...]) -> str:
76
+ # Labels each prior entry so agents cannot confuse board order.
77
+ return "\n".join(f"[{index}] {entry}" for index, entry in entries)
78
+
79
+ def render_prompt(self, task: str, index: int, context: str | None) -> str:
80
+ # A None context is an isolated board: the prior-results section is absent, not
81
+ # merely empty, so no agent can infer that earlier work exists to be reconciled.
82
+ if context is None:
83
+ return f"Task {index}: {task}\n\nComplete only this task."
84
+ prior = context if context.strip() else "(no prior results)"
85
+ return f"Task {index}: {task}\n\nPrior summaries:\n{prior}\n\nComplete only this task."
86
+
87
+ def render_decompose_prompt(self, task: str, index: int, max_subtasks: int) -> str:
88
+ # Names the native tool without putting control syntax into the final text channel.
89
+ return (
90
+ f"Task {index}: {task}\n\nComplete only this task. You see only this task. "
91
+ "If splitting it improves execution, call the provided decompose_tool with "
92
+ f"2 to {max_subtasks} ordered, self-contained subtasks. Otherwise complete the "
93
+ "task normally."
94
+ )
95
+
96
+ def windowed(
97
+ self, entries: list[str | None], index: int, window: int
98
+ ) -> tuple[tuple[int, str], ...]:
99
+ # Slices the trailing window so task N sees exactly N-window..N-1. Unfilled positions
100
+ # are skipped rather than closed up, so a sparse board keeps its real board indices.
101
+ start = max(0, index - window)
102
+ sliced = ((start + offset, entries[start + offset]) for offset in range(index - start))
103
+ return tuple((position, entry) for position, entry in sliced if entry is not None)
104
+
105
+ def _truncate_tail(self, text: str, limit: int) -> str:
106
+ # Keeps a prefix and names how much was removed.
107
+ removed = len(text) - limit
108
+ return f"{text[:limit]}...[truncated {removed} chars]"
109
+
110
+ def _head_tail(self, text: str, limit: int) -> str:
111
+ # Keeps head plus tail evenly so endings survive summarization.
112
+ half = limit // 2
113
+ rest = limit - half
114
+ removed = len(text) - limit
115
+ return f"{text[:half]}...[truncated {removed} chars]...{text[len(text) - rest :]}"
116
+
117
+
118
+ _DECOMPOSE_TOOL_DESCRIPTION = (
119
+ "Replace the current board task with a short ordered list of self-contained subtasks that "
120
+ "run in its place, one after another, before any later task on the board starts. "
121
+ "Call this tool only when the current task is genuinely better handled as separate steps, "
122
+ "for example when it bundles independent deliverables or needs one step's output before "
123
+ "the next can begin; if the task is small or already a single coherent change, do not "
124
+ "call it and complete the task yourself. "
125
+ "Pass at least two distinct subtasks and no more than the maximum named in your task "
126
+ "prompt; blank entries, entries over 20,000 characters, and case-insensitive duplicates "
127
+ "are dropped, and anything past the maximum is ignored, so a call left with fewer than two "
128
+ "valid subtasks is rejected and the task stays whole. "
129
+ "Only the first accepted call in this turn counts, so a second call cannot revise the "
130
+ "split; decide on the full list before calling. "
131
+ "Each child runs later in a fresh agent that sees only its own subtask string, with no "
132
+ "access to this conversation, the parent task, its siblings, or any prior results, and "
133
+ "that child cannot decompose again. "
134
+ "Write every subtask as a complete instruction that names the files, constraints, and "
135
+ "acceptance criteria it needs, because anything left implicit is lost. "
136
+ "After a call is accepted, end your turn with a brief final message describing the split "
137
+ "instead of doing the subtasks' work yourself, since the children will do it."
138
+ )
139
+ _DECOMPOSE_SUBTASKS_DESCRIPTION = (
140
+ "The ordered list of child task statements that replaces the current task at its board "
141
+ "position, where the first string runs first and the last runs last. "
142
+ "Provide between two and the maximum number of subtasks named in your task prompt, each "
143
+ "a distinct, non-blank instruction under 20,000 characters. "
144
+ "Every string is handed verbatim to a fresh isolated agent that has never seen the parent "
145
+ "task, its siblings, or any earlier board results, so each must restate the goal, the "
146
+ "relevant files or inputs, the constraints, and what done looks like. "
147
+ "Order the list so that any subtask relying on another's changes comes after it, because "
148
+ "children run strictly in list order and nothing passes between them except the files "
149
+ "they change. "
150
+ "Entries are stripped of surrounding whitespace, case-insensitive duplicates keep only "
151
+ "their first occurrence, and entries past the maximum are discarded rather than rejected. "
152
+ "Do not include numbering, bullet markers, or commentary about the split itself, since "
153
+ "each string becomes a task prompt exactly as written."
154
+ )
155
+
156
+
157
+ class TaskBoardDecomposeCapture:
158
+ """Captures one parent attempt's accepted native decomposition call."""
159
+
160
+ def __init__(self, max_subtasks: int) -> None:
161
+ # Keeps the attempt-local bound and prevents concurrent tool calls from overwriting it.
162
+ self._max_subtasks = max_subtasks
163
+ self._accepted: tuple[str, ...] = ()
164
+ self._lock = Lock()
165
+
166
+ @property
167
+ def accepted_subtasks(self) -> tuple[str, ...]:
168
+ # Returns an immutable snapshot for the session's post-turn splice decision.
169
+ with self._lock:
170
+ return self._accepted
171
+
172
+ def build_tool(self) -> FunctionTool:
173
+ # Uses the SDK's public decorator lazily so command help never starts Codex machinery.
174
+ from vidbyte.tools import tool
175
+
176
+ def decompose_tool(
177
+ subtasks: Annotated[
178
+ list[str],
179
+ Field(description=_DECOMPOSE_SUBTASKS_DESCRIPTION),
180
+ ],
181
+ ) -> str:
182
+ # Routes the model's structured call into this attempt's policy-bound capture.
183
+ return self._accept(subtasks)
184
+
185
+ return tool(name="decompose_tool", description=_DECOMPOSE_TOOL_DESCRIPTION)(decompose_tool)
186
+
187
+ def _accept(self, candidates: list[str]) -> str:
188
+ # Normalizes candidates before the first accepted call becomes immutable.
189
+ cleaned = self._clean_candidates(candidates)
190
+ if len(cleaned) < int(TaskBoardLimit.MIN_SUBTASKS):
191
+ return "No decomposition accepted; provide at least two distinct valid subtasks."
192
+ with self._lock:
193
+ if self._accepted:
194
+ return "A decomposition was already accepted for this task attempt."
195
+ self._accepted = cleaned
196
+ return f"Accepted {len(cleaned)} subtasks for isolated execution."
197
+
198
+ def _clean_candidates(self, candidates: list[str]) -> tuple[str, ...]:
199
+ # Drops unsafe entries, preserves first occurrence order, and enforces the local cap.
200
+ seen: set[str] = set()
201
+ kept: list[str] = []
202
+ for candidate in candidates:
203
+ cleaned = candidate.strip()
204
+ if not cleaned or len(cleaned) > int(TaskBoardLimit.MAX_TASK_CHARS):
205
+ continue
206
+ folded = cleaned.casefold()
207
+ if folded in seen:
208
+ continue
209
+ seen.add(folded)
210
+ kept.append(cleaned)
211
+ if len(kept) >= self._max_subtasks:
212
+ break
213
+ return tuple(kept)
214
+
215
+
216
+ class TaskBoardCodexSession:
217
+ """One board invocation's environment, per-task SDK agents, and checkpoint policy."""
218
+
219
+ def __init__(
220
+ self,
221
+ environment: Mapping[str, str],
222
+ progress: Callable[[str], None],
223
+ stream: Callable[[TaskBoardCheckpoint], None] | None = None,
224
+ ) -> None:
225
+ # Stores env, progress, and the optional per-step stream without starting any turn.
226
+ self._environment = dict(environment)
227
+ self._progress = progress
228
+ self._stream = stream
229
+ self._executable = ""
230
+ self._working_directory = ""
231
+ self._summarizer = TaskBoardSummarizer()
232
+ self._checkpointer: TaskBoardCheckpointer | None = None
233
+ self._controls = TaskBoardRunControls()
234
+
235
+ def with_checkpoints(
236
+ self, checkpointer: TaskBoardCheckpointer, controls: TaskBoardRunControls
237
+ ) -> None:
238
+ # Arms durability plus every resume, replay, slicing, and budget bound in one object,
239
+ # so the session never carries a half-configured mix of run controls.
240
+ self._checkpointer = checkpointer
241
+ self._controls = controls
242
+
243
+ def prepare(self, plan: Plan) -> None:
244
+ # Loads the SDK and records paths without starting a paid model turn.
245
+ from vidbyte.agents.codex import CodexHarnessAgent # noqa: F401
246
+
247
+ self._executable = str(plan.executable)
248
+ self._working_directory = str(plan.working_directory)
249
+
250
+ def run(self, plan: Plan, settings: TaskBoardSettings, admission_id: str) -> TaskBoardResult:
251
+ # Runs the ordered board synchronously for command-layer callers.
252
+ try:
253
+ if self._controls.replay_index is not None:
254
+ index = self._controls.replay_index
255
+ return asyncio.run(self._replay(settings, admission_id, index))
256
+ return asyncio.run(self._run(plan, settings, admission_id))
257
+ except TaskBoardHostFailed:
258
+ raise
259
+ except (TimeoutError, RuntimeError) as error:
260
+ raise TaskBoardHostFailed() from error
261
+
262
+ def preview_prompt(self, settings: TaskBoardSettings, index: int) -> str:
263
+ # Rebuilds one step's exact prompt from stored entries without starting an agent, so a
264
+ # window or handoff mistake is caught before a caller pays for the turn that shows it.
265
+ checkpointer = self._require_checkpointer()
266
+ if index >= len(settings.tasks):
267
+ raise TaskBoardCheckpointMismatch("replay-past-end")
268
+ checkpointer.validate_manifest(settings)
269
+ order = self._order(settings)
270
+ entries = self._empty_entries(settings)
271
+ self._seed(entries, [], checkpointer.load_prefix(order.index(index), order), settings)
272
+ return self._build_prompt(settings.tasks[index], index, entries, settings)
273
+
274
+ async def _run(
275
+ self, plan: Plan, settings: TaskBoardSettings, admission_id: str
276
+ ) -> TaskBoardResult:
277
+ # Runs the selected indices in execution order, each in a fresh agent, recording every
278
+ # attempted step before the next one starts so board indices and handoff entries stay
279
+ # aligned. A linear board runs in board order; a DAG board runs parents first, and a
280
+ # task whose dependency failed is recorded failed without ever starting an agent.
281
+ del plan
282
+ if settings.allow_decompose:
283
+ return await self._run_decomposing(settings, admission_id)
284
+ order = self._order(settings)
285
+ dag = settings.execution_type is TaskBoardExecutionType.DAG
286
+ parents = self._dag_parents(settings)
287
+ entries = self._empty_entries(settings)
288
+ steps: list[TaskBoardStepResult] = []
289
+ self._progress(Progress.TASK_STARTING)
290
+ if dag:
291
+ self._progress(Progress.DAG_PLAN_READY)
292
+ prefix = self._load_prefix(entries, steps, settings, order)
293
+ completed, failed = prefix.completed, prefix.failed
294
+ # A stored failure blocks its dependents exactly like a failure in this invocation.
295
+ blocked = {step.index for step in steps if step.status == "failed"}
296
+ # `spent` is this invocation's usage and is what the budget guard reads, because a
297
+ # resumed board would otherwise halt on the spend its earlier invocations already
298
+ # made. `tokens` stays board-cumulative, since that is what the result reports.
299
+ tokens, spent, started = prefix.total_tokens, 0, 0
300
+ stopped: str | None = None
301
+ for index in self._indices(prefix, order):
302
+ if stopped := self._halt_reason(started, spent):
303
+ break
304
+ blockers = sorted(parent for parent in parents[index] if parent in blocked)
305
+ if dag and blockers:
306
+ # A skip starts no agent, so it neither costs nor counts toward --stop-after.
307
+ turn, note = None, self._skip_detail(blockers)
308
+ self._progress(Progress.TASK_SKIPPED)
309
+ else:
310
+ started += 1
311
+ outcome = await self._run_task(settings.tasks[index], index, entries, settings)
312
+ turn, note = outcome.turn, outcome.note
313
+ if turn is None and dag:
314
+ self._progress(Progress.TASK_FAILED)
315
+ used = 0 if turn is None else (turn.total_tokens or 0)
316
+ spent, tokens = spent + used, tokens + used
317
+ # Only a DAG failure carries its note: the linear placeholder is what windowed
318
+ # prompts read, so it keeps its exact wording.
319
+ step = self._step_of(settings.tasks[index], index, turn, note if dag else "")
320
+ self._record(step, turn, entries, settings, admission_id, order)
321
+ steps.append(step)
322
+ if turn is None:
323
+ blocked.add(index)
324
+ completed, failed = completed + int(turn is not None), failed + int(turn is None)
325
+ if turn is None and settings.stop_on_error:
326
+ stopped = "stop-on-error"
327
+ break
328
+ self._progress(Progress.COMPLETE)
329
+ return self._result(settings, admission_id, completed, failed, steps, tokens, stopped)
330
+
331
+ async def _run_decomposing(
332
+ self, settings: TaskBoardSettings, admission_id: str
333
+ ) -> TaskBoardResult:
334
+ # Replaces eligible parents in a mutable linear board while preserving execution order.
335
+ # This loop exists apart from `_run` because a splice changes the board's length while
336
+ # it runs: `_run` iterates a precomputed execution order and seeds handoff entries by
337
+ # board index, and both assumptions break the moment one task becomes several.
338
+ # `work` is the live board and `depths` runs parallel to it, so the two lists must be
339
+ # spliced together or a child would inherit the wrong decompose eligibility. Every
340
+ # original task starts at depth zero, meaning it may still decompose.
341
+ work = list(settings.tasks)
342
+ depths = [0] * len(work)
343
+ steps: list[TaskBoardStepResult] = []
344
+ # Counters are local to this invocation because the command rejects checkpointing for
345
+ # a decomposing board, so there is no stored prefix to resume from or add to.
346
+ completed, failed, tokens = 0, 0, 0
347
+ stopped: str | None = None
348
+ self._progress(Progress.TASK_STARTING)
349
+ # A `while` over a manual index rather than a `for` over the list, because a splice
350
+ # must re-read `len(work)` and must be able to leave `index` where it is.
351
+ index = 0
352
+ while index < len(work):
353
+ task = work[index]
354
+ # The limit is recomputed per task from the live board size, so a late parent on a
355
+ # board that has already grown near the 500-task ceiling gets a smaller cap, and
356
+ # `None` withholds the tool entirely for children and for a board with no room.
357
+ decompose_limit = self._decompose_limit(len(work), depths[index], settings)
358
+ # Every task runs isolated with an empty entry list: a splice shifts board indices,
359
+ # so windowed or dependency context would point at the wrong earlier results.
360
+ outcome = await self._run_task(
361
+ task,
362
+ index,
363
+ [],
364
+ settings,
365
+ isolated=True,
366
+ decompose_max_subtasks=decompose_limit,
367
+ )
368
+ turn = outcome.turn
369
+ if turn is None:
370
+ # A failed parent never splices, even if its tool call was accepted before the
371
+ # turn died: the capture is attempt-local, and only a completed turn returns it.
372
+ failed += 1
373
+ steps.append(self._step_of(task, index, None))
374
+ if settings.stop_on_error:
375
+ stopped = "stop-on-error"
376
+ break
377
+ index += 1
378
+ continue
379
+ # A completed parent counts as completed and pays its tokens whether or not it
380
+ # decomposed, because its turn ran to the end either way and was billed for.
381
+ completed += 1
382
+ tokens += turn.total_tokens or 0
383
+ children = outcome.subtasks
384
+ # The capture already rejects a call with fewer than two valid subtasks, so this
385
+ # threshold is a second guard that keeps a one-child "split" from ever replacing a
386
+ # task with a copy of itself.
387
+ if len(children) >= int(TaskBoardLimit.MIN_SUBTASKS):
388
+ # The children take the parent's slot in place, so every later task shifts right
389
+ # by `len(children) - 1` and still runs after all of them. Depth one marks each
390
+ # child ineligible, which is what keeps decomposition to a single level.
391
+ work[index : index + 1] = list(children)
392
+ depths[index : index + 1] = [1] * len(children)
393
+ steps.append(self._decomposed_step(task, index, len(children), turn.thread_id))
394
+ # `index` is deliberately not advanced: the first child now sits at this
395
+ # position, so the next iteration runs it rather than skipping past it.
396
+ continue
397
+ # No accepted split means the parent's own final text is the task's result, the
398
+ # same step shape a board without --allow-decompose records.
399
+ steps.append(self._step_of(task, index, turn))
400
+ index += 1
401
+ self._progress(Progress.COMPLETE)
402
+ # `steps` stays in append order because a splice reuses a parent's index for its first
403
+ # child, so the result lists each decomposed parent directly before the children that
404
+ # replaced it.
405
+ return self._result(settings, admission_id, completed, failed, steps, tokens, stopped)
406
+
407
+ def _decompose_limit(
408
+ self, current_size: int, depth: int, settings: TaskBoardSettings
409
+ ) -> int | None:
410
+ # Omits the tool for children and for a board with fewer than two available slots.
411
+ capacity = int(TaskBoardLimit.MAX_TASKS) - current_size + 1
412
+ if depth or capacity < int(TaskBoardLimit.MIN_SUBTASKS):
413
+ return None
414
+ return min(settings.max_subtasks, capacity)
415
+
416
+ async def _replay(
417
+ self, settings: TaskBoardSettings, admission_id: str, index: int
418
+ ) -> TaskBoardResult:
419
+ # Rebuilds one step's exact prompt from stored entries and runs only that step, so a
420
+ # flaky step can be chased repeatedly without disturbing anything else on the board.
421
+ checkpointer = self._require_checkpointer()
422
+ if index >= len(settings.tasks):
423
+ raise TaskBoardCheckpointMismatch("replay-past-end")
424
+ checkpointer.validate_manifest(settings)
425
+ order = self._order(settings)
426
+ entries = self._empty_entries(settings)
427
+ self._progress(Progress.TASK_STARTING)
428
+ self._seed(entries, [], checkpointer.load_prefix(order.index(index), order), settings)
429
+ outcome = await self._run_task(settings.tasks[index], index, entries, settings)
430
+ turn, note = outcome.turn, outcome.note
431
+ dag = settings.execution_type is TaskBoardExecutionType.DAG
432
+ step = self._step_of(settings.tasks[index], index, turn, note if dag else "")
433
+ tokens = 0 if turn is None else (turn.total_tokens or 0)
434
+ self._record(step, turn, entries, settings, admission_id, order)
435
+ self._progress(Progress.COMPLETE)
436
+ return self._result(
437
+ settings, admission_id, int(turn is not None), int(turn is None), [step], tokens, None
438
+ )
439
+
440
+ def _load_prefix(
441
+ self,
442
+ entries: list[str | None],
443
+ steps: list[TaskBoardStepResult],
444
+ settings: TaskBoardSettings,
445
+ order: tuple[int, ...],
446
+ ) -> TaskBoardPrefix:
447
+ # Seeds the handoff entries and the result list from stored steps; a fresh run only
448
+ # stamps the manifest, which is what makes this board addressable by id from then on.
449
+ # `--from N` counts positions in the execution order, so on a DAG board it skips the
450
+ # first N steps that ran rather than board indices 0 through N-1.
451
+ if self._checkpointer is None:
452
+ return TaskBoardPrefix(start_index=0, completed=0, failed=0, total_tokens=0)
453
+ if self._controls.start_from > len(settings.tasks):
454
+ raise TaskBoardCheckpointMismatch("resume-past-end")
455
+ if self._controls.start_from == 0 and not self._controls.retry_failed_only:
456
+ self._stamp_manifest(settings)
457
+ return TaskBoardPrefix(start_index=0, completed=0, failed=0, total_tokens=0)
458
+ self._checkpointer.validate_manifest(settings)
459
+ if self._controls.retry_failed_only:
460
+ return self._repair_prefix(entries, steps, settings, order)
461
+ stored = self._checkpointer.load_prefix(self._controls.start_from, order)
462
+ self._seed(entries, steps, stored, settings)
463
+ return TaskBoardPrefix(
464
+ start_index=len(stored),
465
+ completed=sum(1 for record in stored if record.status == "completed"),
466
+ failed=sum(1 for record in stored if record.status == "failed"),
467
+ total_tokens=sum(record.total_tokens or 0 for record in stored),
468
+ )
469
+
470
+ def _repair_prefix(
471
+ self,
472
+ entries: list[str | None],
473
+ steps: list[TaskBoardStepResult],
474
+ settings: TaskBoardSettings,
475
+ order: tuple[int, ...],
476
+ ) -> TaskBoardPrefix:
477
+ # A repair pass re-runs only the stored failures, so a board with 97 successes and 3
478
+ # failures costs three turns rather than paying again for work that already landed.
479
+ # Failures re-run in execution order, so on a DAG board a repaired parent runs before
480
+ # the dependent it had blocked, and that dependent then gets a real attempt.
481
+ checkpointer = self._require_checkpointer()
482
+ stored = checkpointer.load_stored(len(settings.tasks))
483
+ failed = {record.index for record in stored if record.status == "failed"}
484
+ failures = tuple(index for index in order[self._controls.start_from :] if index in failed)
485
+ # A failure about to be re-run seeds its window entry but contributes no step record
486
+ # and no failed count, because the loop is about to produce both for that same index.
487
+ self._seed(entries, steps, stored, settings, skip=frozenset(failures))
488
+ return TaskBoardPrefix(
489
+ start_index=self._controls.start_from,
490
+ completed=sum(1 for record in stored if record.status == "completed"),
491
+ failed=sum(1 for record in stored if record.status == "failed") - len(failures),
492
+ total_tokens=sum(record.total_tokens or 0 for record in stored),
493
+ replay_indices=failures,
494
+ )
495
+
496
+ def _indices(self, prefix: TaskBoardPrefix, order: tuple[int, ...]) -> tuple[int, ...]:
497
+ # The exact board positions this invocation will attempt, in execution order.
498
+ if self._controls.retry_failed_only:
499
+ return prefix.replay_indices
500
+ return order[prefix.start_index :]
501
+
502
+ def _halt_reason(self, started: int, tokens: int) -> str | None:
503
+ # Budgets and slicing are checked between steps, so at most one step can overshoot a
504
+ # limit and the caller always gets a resume command instead of a truncated board.
505
+ if self._controls.stop_after is not None and started >= self._controls.stop_after:
506
+ return "stop-after"
507
+ return self._controls.exhausted(tokens)
508
+
509
+ def _seed(
510
+ self,
511
+ entries: list[str | None],
512
+ steps: list[TaskBoardStepResult],
513
+ stored: tuple[TaskBoardCheckpoint, ...],
514
+ settings: TaskBoardSettings,
515
+ skip: frozenset[int] = frozenset(),
516
+ ) -> None:
517
+ # Replays stored records into this run's context, rebuilding each handoff entry from
518
+ # the stored task and result so a resumed prompt matches what a fresh run would build.
519
+ for record in stored:
520
+ entries[record.index] = self._summarizer.handoff(
521
+ settings, record.task, record.summary, record.result_text
522
+ )
523
+ if record.index in skip:
524
+ continue
525
+ steps.append(
526
+ TaskBoardStepResult(
527
+ index=record.index,
528
+ task=record.task,
529
+ summary=record.summary,
530
+ status=record.status,
531
+ thread_id=record.thread_id,
532
+ )
533
+ )
534
+
535
+ def _record(
536
+ self,
537
+ step: TaskBoardStepResult,
538
+ turn: TaskBoardTurn | None,
539
+ entries: list[str | None],
540
+ settings: TaskBoardSettings,
541
+ admission_id: str,
542
+ order: tuple[int, ...],
543
+ ) -> None:
544
+ # Fills this step's handoff entry, then applies the checkpoint policy. Order matters:
545
+ # the entry has to exist before the next task builds its prompt, and the checkpoint has
546
+ # to be written before the next task starts, or a crash loses a step that really ran.
547
+ result = "" if turn is None else turn.result_text
548
+ entries[step.index] = self._summarizer.handoff(settings, step.task, step.summary, result)
549
+ if self._checkpointer is None:
550
+ return
551
+ fallback = self._build_prompt(step.task, step.index, entries, settings)
552
+ prompt = turn.prompt if turn else fallback
553
+ record = TaskBoardCheckpoint(
554
+ board_id=self._checkpointer.board_id,
555
+ index=step.index,
556
+ parent_index=self._parent_index(entries, step.index, order),
557
+ task=step.task,
558
+ prompt=prompt,
559
+ summary=step.summary,
560
+ result_text=result,
561
+ status=step.status,
562
+ thread_id=step.thread_id,
563
+ total_tokens=None if turn is None else turn.total_tokens,
564
+ estimated_cost_usd=self._step_cost(turn),
565
+ admission_id=admission_id,
566
+ created_at=TaskBoardCheckpointer.timestamp(),
567
+ )
568
+ self._persist(record)
569
+
570
+ def _persist(self, record: TaskBoardCheckpoint) -> None:
571
+ # Checkpointing never fails a step: a write error is reported on stderr and the board
572
+ # keeps its progress, because losing a paid turn to a full disk is the worse outcome.
573
+ checkpointer = self._require_checkpointer()
574
+ try:
575
+ step_file = checkpointer.write_step(record)
576
+ except LocalFileWriteFailed:
577
+ self._progress(f"Checkpoint write failed for task {record.index}; continuing.")
578
+ return
579
+ if self._controls.checkpoint_mode is TaskBoardCheckpointMode.STREAM and self._stream:
580
+ self._stream(record)
581
+ if self._controls.checkpoint_mode is TaskBoardCheckpointMode.EXPORT:
582
+ self._append_export(checkpointer, record)
583
+ if self._controls.on_checkpoint and not checkpointer.run_hook(
584
+ self._controls.on_checkpoint, record, step_file
585
+ ):
586
+ self._progress(f"Checkpoint hook failed for task {record.index}; continuing.")
587
+
588
+ def _append_export(
589
+ self, checkpointer: TaskBoardCheckpointer, record: TaskBoardCheckpoint
590
+ ) -> None:
591
+ # The export log is an observer of the same event, so its failure is also non-fatal.
592
+ try:
593
+ checkpointer.append_export(record, checkpointer.export_path(self._controls.export_file))
594
+ except (LocalFileWriteFailed, OSError):
595
+ # OSError: resolving a configured export path can fail before the store is reached.
596
+ self._progress(f"Checkpoint export failed for task {record.index}; continuing.")
597
+
598
+ def _parent_index(
599
+ self, entries: list[str | None], index: int, order: tuple[int, ...]
600
+ ) -> int | None:
601
+ # The nearest filled step before this one in execution order, which links stored steps
602
+ # into the chain a resume walks. On a linear board that is the nearest lower index.
603
+ before = [other for other in order[: order.index(index)] if entries[other] is not None]
604
+ return before[-1] if before else None
605
+
606
+ def _step_cost(self, turn: TaskBoardTurn | None) -> float | None:
607
+ # Unknown usage stays None rather than 0.0, so a budget never treats it as free.
608
+ if turn is None or turn.total_tokens is None:
609
+ return None
610
+ return self._controls.cost_of(turn.total_tokens)
611
+
612
+ def _stamp_manifest(self, settings: TaskBoardSettings) -> None:
613
+ # A board that cannot record its manifest is still worth running; only resume is lost.
614
+ try:
615
+ self._require_checkpointer().write_manifest(settings)
616
+ except LocalFileWriteFailed:
617
+ self._progress("Checkpoint manifest write failed; continuing.")
618
+
619
+ def _require_checkpointer(self) -> TaskBoardCheckpointer:
620
+ # Resume, replay, preview, and repair are unreachable without one; the command arms it.
621
+ if self._checkpointer is None:
622
+ raise RuntimeError("task board checkpoint operation requested without a checkpointer")
623
+ return self._checkpointer
624
+
625
+ @staticmethod
626
+ def _empty_entries(settings: TaskBoardSettings) -> list[str | None]:
627
+ # One slot per board position, so a sparse board keeps its real indices.
628
+ return [None] * len(settings.tasks)
629
+
630
+ @staticmethod
631
+ def _order(settings: TaskBoardSettings) -> tuple[int, ...]:
632
+ # The settings validator already rejects cycles; this guard keeps a model built
633
+ # without validation from silently running nothing.
634
+ order = settings.execution_order()
635
+ if len(order) != len(settings.tasks):
636
+ raise TaskBoardDependencyInvalid()
637
+ return order
638
+
639
+ def _dag_parents(self, settings: TaskBoardSettings) -> dict[int, tuple[int, ...]]:
640
+ # Maps each task to its sorted direct parents for context selection and skipping.
641
+ grouped: dict[int, list[int]] = {index: [] for index in range(len(settings.tasks))}
642
+ for child, parent in settings.dependencies:
643
+ grouped[child].append(parent)
644
+ return {index: tuple(sorted(parents)) for index, parents in grouped.items()}
645
+
646
+ async def _run_task(
647
+ self,
648
+ task: str,
649
+ index: int,
650
+ entries: list[str | None],
651
+ settings: TaskBoardSettings,
652
+ *,
653
+ isolated: bool = False,
654
+ decompose_max_subtasks: int | None = None,
655
+ ) -> TaskBoardTaskOutcome:
656
+ # One task start to finish. Every attempt builds its own agent and renders the same
657
+ # prompt, so a retry recovers from a dead host rather than re-deciding the context.
658
+ # A failure also returns the note a DAG step records: attempt count, failure kind, and
659
+ # any partial agent text, never task content.
660
+ attempts = settings.max_retries_per_task + 1
661
+ last_note = ""
662
+ for attempt in range(attempts):
663
+ if attempt > 0:
664
+ self._progress(Progress.TASK_RETRYING)
665
+ reply: AgentMessage | None = None
666
+ try:
667
+ capture = (
668
+ None
669
+ if decompose_max_subtasks is None
670
+ else TaskBoardDecomposeCapture(decompose_max_subtasks)
671
+ )
672
+ prompt = self._build_prompt(
673
+ task,
674
+ index,
675
+ entries,
676
+ settings,
677
+ isolated=isolated,
678
+ decompose_max_subtasks=decompose_max_subtasks,
679
+ )
680
+ tool = None if capture is None else capture.build_tool()
681
+ agent = (
682
+ self._build_agent(index, settings)
683
+ if tool is None
684
+ else self._build_agent(index, settings, tool)
685
+ )
686
+ reply = await self._turn(agent, prompt, settings)
687
+ result = self._completed_text(reply)
688
+ turn = TaskBoardTurn(
689
+ prompt=prompt,
690
+ summary=self._summarizer.summarize(
691
+ result, settings.summary_mode.value, settings.summary_max_chars
692
+ ),
693
+ result_text=result,
694
+ thread_id=self._thread_id(reply),
695
+ total_tokens=self._usage_of(reply),
696
+ )
697
+ subtasks = () if capture is None else capture.accepted_subtasks
698
+ return TaskBoardTaskOutcome(turn=turn, note="", subtasks=subtasks)
699
+ except Exception as error:
700
+ # Attempt failures stay local: stop-on-error is the caller's policy, and
701
+ # only the final note survives so earlier attempts never leak stale causes.
702
+ last_note = self._attempt_note(error, reply, settings, attempt, attempts)
703
+ continue
704
+ return TaskBoardTaskOutcome(
705
+ turn=None,
706
+ note=f"Ran {attempts} attempt(s), all exhausted. {last_note}",
707
+ )
708
+
709
+ def _attempt_note(
710
+ self,
711
+ error: Exception,
712
+ reply: AgentMessage | None,
713
+ settings: TaskBoardSettings,
714
+ attempt: int,
715
+ attempts: int,
716
+ ) -> str:
717
+ # Names what ended one attempt so the recorded failure explains itself: timeouts
718
+ # are told apart from dead hosts, and an incomplete turn keeps the partial text
719
+ # its agent left behind, bounded by the same summarizer prompts already use.
720
+ if isinstance(error.__cause__, TimeoutError):
721
+ kind = "the Codex turn timed out before returning"
722
+ elif reply is not None and self._partial_text(reply):
723
+ partial = self._summarizer.summarize(
724
+ self._partial_text(reply),
725
+ settings.summary_mode.value,
726
+ settings.summary_max_chars,
727
+ )
728
+ kind = f"the agent returned an incomplete turn, leaving: {partial}"
729
+ else:
730
+ kind = "the Codex host failed before returning a completed turn"
731
+ return f"Attempt {attempt + 1} of {attempts}: {kind}."
732
+
733
+ def _partial_text(self, reply: AgentMessage) -> str:
734
+ # Reads whatever text an incomplete turn left behind without validating it, so a
735
+ # failure note can carry the agent's own words even when the turn never completed.
736
+ try:
737
+ return str(reply.content or "").strip()
738
+ except Exception:
739
+ return ""
740
+
741
+ def _skip_detail(self, blockers: list[int]) -> str:
742
+ # Names the failed dependencies so a skip is actionable without re-reading the graph.
743
+ names = ", ".join(f"task {parent}" for parent in blockers)
744
+ return (
745
+ f"No agent was started because {names} did not complete. "
746
+ "Fix or re-run the failed dependencies, then re-run this task."
747
+ )
748
+
749
+ def _usage_of(self, reply: AgentMessage) -> int | None:
750
+ # Reads cumulative provider tokens when reported; unknown usage is None, never zero.
751
+ data = reply.codex
752
+ if data is None or not getattr(data, "usage_available", False):
753
+ return None
754
+ total = getattr(data.usage, "total_tokens", None)
755
+ return total if isinstance(total, int) and total >= 0 else None
756
+
757
+ def _build_prompt(
758
+ self,
759
+ task: str,
760
+ index: int,
761
+ entries: list[str | None],
762
+ settings: TaskBoardSettings,
763
+ *,
764
+ isolated: bool = False,
765
+ decompose_max_subtasks: int | None = None,
766
+ ) -> str:
767
+ # An isolated board hands its agents no prior-results channel at all; a windowed board
768
+ # renders exactly the trailing slice the window admits and nothing older.
769
+ if decompose_max_subtasks is not None:
770
+ return self._summarizer.render_decompose_prompt(task, index, decompose_max_subtasks)
771
+ if isolated:
772
+ return self._summarizer.render_prompt(task, index, None)
773
+ if settings.context_mode is TaskBoardContextMode.ISOLATED:
774
+ return self._summarizer.render_prompt(task, index, None)
775
+ if settings.execution_type is TaskBoardExecutionType.DAG:
776
+ return self._build_dag_prompt(task, index, entries, settings)
777
+ windowed = self._summarizer.windowed(entries, index, settings.window)
778
+ context = self._summarizer.render_context(windowed)
779
+ return self._summarizer.render_prompt(task, index, context)
780
+
781
+ def _build_dag_prompt(
782
+ self, task: str, index: int, entries: list[str | None], settings: TaskBoardSettings
783
+ ) -> str:
784
+ # Renders only direct dependencies' entries so unrelated context never leaks in.
785
+ selected: list[tuple[int, str]] = []
786
+ for parent in self._dag_parents(settings)[index]:
787
+ entry = entries[parent]
788
+ if entry:
789
+ selected.append((parent, entry))
790
+ context = self._summarizer.render_context(tuple(selected))
791
+ return self._summarizer.render_prompt(task, index, context)
792
+
793
+ def _build_agent(
794
+ self, index: int, settings: TaskBoardSettings, decompose_tool: FunctionTool | None = None
795
+ ) -> CodexHarnessAgent:
796
+ # Constructs a fresh agent so threads never leak across tasks. The client config is
797
+ # built here rather than in a helper because its type only exists under this import.
798
+ from vidbyte.agents.codex import CodexHarnessAgent
799
+ from vidbyte.lib.dataclasses.codex import (
800
+ CodexAgentSettings,
801
+ CodexClientSettings,
802
+ CodexHarnessAgentSettings,
803
+ CodexThreadSettings,
804
+ CodexTurnSettings,
805
+ )
806
+ from vidbyte.lib.enums.codex import CodexReasoningEffort, CodexSandbox
807
+
808
+ client = CodexClientSettings(
809
+ codex_bin=self._executable,
810
+ cwd=self._working_directory,
811
+ env=self._environment,
812
+ experimental_api=True,
813
+ config_overrides=tuple(setting.value for setting in TaskBoardCodexConfig),
814
+ )
815
+ return CodexHarnessAgent(
816
+ CodexHarnessAgentSettings(
817
+ name=f"task-board-{index}",
818
+ system_prompt=self._prompt(),
819
+ codex=CodexAgentSettings(
820
+ client=client,
821
+ thread=CodexThreadSettings(
822
+ model=settings.agent.model,
823
+ sandbox=CodexSandbox(settings.agent.sandbox.value),
824
+ ),
825
+ turn=CodexTurnSettings(
826
+ effort=CodexReasoningEffort(settings.agent.reasoning_effort.value)
827
+ ),
828
+ ),
829
+ tools=() if decompose_tool is None else (decompose_tool,),
830
+ )
831
+ )
832
+
833
+ async def _turn(
834
+ self, agent: CodexHarnessAgent, prompt: str, settings: TaskBoardSettings
835
+ ) -> AgentMessage:
836
+ # Cancels slow turns so the SDK client unwinds before continuing.
837
+ from vidbyte.lib.dataclasses.codex import CodexRunInput
838
+
839
+ try:
840
+ async with asyncio.timeout(settings.agent.turn_timeout_seconds):
841
+ return await agent.arun(CodexRunInput.text(prompt))
842
+ except Exception as error:
843
+ # This covers the timeout too, which asyncio raises as a plain TimeoutError.
844
+ raise TaskBoardHostFailed() from error
845
+
846
+ def _completed_text(self, reply: AgentMessage) -> str:
847
+ # Rejects incomplete turns without exposing model content in errors.
848
+ data = reply.codex
849
+ if data is None or data.status != "completed" or not data.final_response:
850
+ raise TaskBoardHostFailed()
851
+ return str(reply.content)
852
+
853
+ def _thread_id(self, reply: AgentMessage) -> str:
854
+ # Requires a stable thread identity for per-step accounting.
855
+ data = reply.codex
856
+ thread = str(data.thread_id) if data is not None else ""
857
+ if not thread.strip():
858
+ raise TaskBoardHostFailed()
859
+ return thread
860
+
861
+ def _step_of(
862
+ self, task: str, index: int, turn: TaskBoardTurn | None, detail: str = ""
863
+ ) -> TaskBoardStepResult:
864
+ # One shape for both outcomes; a failure keeps a placeholder summary so board indices
865
+ # and the window slice stay aligned with what a fully successful run would have built.
866
+ # A DAG failure appends its reason: the failed parents, or the final attempt note.
867
+ if turn is None:
868
+ return TaskBoardStepResult(
869
+ index=index,
870
+ task=task,
871
+ summary=f"Task {index} failed. {detail}" if detail else f"Task {index} failed.",
872
+ status="failed",
873
+ thread_id=f"task-board-{index}-failed",
874
+ )
875
+ return TaskBoardStepResult(
876
+ index=index,
877
+ task=task,
878
+ summary=turn.summary,
879
+ status="completed",
880
+ thread_id=turn.thread_id,
881
+ )
882
+
883
+ def _decomposed_step(
884
+ self, task: str, index: int, count: int, thread: str
885
+ ) -> TaskBoardStepResult:
886
+ # Records the parent turn that expanded into children at its current position.
887
+ return TaskBoardStepResult(
888
+ index=index,
889
+ task=task,
890
+ summary=f"Task {index} decomposed into {count} subtasks.",
891
+ status="completed",
892
+ thread_id=thread,
893
+ )
894
+
895
+ def _result(
896
+ self,
897
+ settings: TaskBoardSettings,
898
+ admission_id: str,
899
+ completed: int,
900
+ failed: int,
901
+ steps: list[TaskBoardStepResult],
902
+ tokens: int,
903
+ stopped: str | None,
904
+ ) -> TaskBoardResult:
905
+ # Joins step summaries into the board-level text and names where the board lives, so a
906
+ # calling agent can address it later by absolute path rather than by remembering a run.
907
+ # Steps are listed in execution order, which is board order unless the board is a DAG.
908
+ if settings.allow_decompose:
909
+ # Mutable expansion can repeat an index, so append order is the only stable order.
910
+ ordered = list(steps)
911
+ else:
912
+ position = {index: rank for rank, index in enumerate(self._order(settings))}
913
+ ordered = sorted(steps, key=lambda step: position[step.index])
914
+ text = "\n".join(f"[{step.index}] {step.summary}" for step in ordered)
915
+ checkpointer = self._checkpointer
916
+ return TaskBoardResult(
917
+ admission_id=admission_id,
918
+ completed=completed,
919
+ failed=failed,
920
+ steps=tuple(ordered),
921
+ board_id=None if checkpointer is None else checkpointer.board_id,
922
+ board_dir=None if checkpointer is None else str(checkpointer.directory),
923
+ export_file=self._result_export(checkpointer),
924
+ report_file=self._write_report(checkpointer),
925
+ resume_command=self._resume_command(checkpointer, settings, ordered),
926
+ total_tokens=tokens or None,
927
+ estimated_cost_usd=self._controls.cost_of(tokens) if tokens else None,
928
+ stopped_reason=stopped,
929
+ text=text,
930
+ )
931
+
932
+ def _result_export(self, checkpointer: TaskBoardCheckpointer | None) -> str | None:
933
+ # Only an export-mode run has a log to point at; other modes report nothing here.
934
+ exporting = self._controls.checkpoint_mode is TaskBoardCheckpointMode.EXPORT
935
+ if checkpointer is None or not exporting:
936
+ return None
937
+ return str(checkpointer.export_path(self._controls.export_file))
938
+
939
+ def _write_report(self, checkpointer: TaskBoardCheckpointer | None) -> str | None:
940
+ # The report is written last, from the same stored chain `status` reads, so the file
941
+ # a human opens and the record an agent queries can never describe different runs.
942
+ # Reading that chain can itself fail when the manifest write failed earlier, and a
943
+ # report is never worth failing a board whose paid work already finished.
944
+ if checkpointer is None or not self._controls.report_file:
945
+ return None
946
+ try:
947
+ report = Path(self._controls.report_file)
948
+ return str(checkpointer.write_report(checkpointer.chain(), report))
949
+ except (LocalFileWriteFailed, OSError):
950
+ # OSError: resolving the configured report path can fail before the store is reached.
951
+ self._progress("Checkpoint report write failed; continuing.")
952
+ return None
953
+ except CliError:
954
+ self._progress("Checkpoint report could not read the board; continuing.")
955
+ return None
956
+
957
+ def _resume_command(
958
+ self,
959
+ checkpointer: TaskBoardCheckpointer | None,
960
+ settings: TaskBoardSettings,
961
+ steps: list[TaskBoardStepResult],
962
+ ) -> str | None:
963
+ # A ready-to-paste continuation, so a caller never reconstructs flags from help text.
964
+ # It points at the first step that did not complete, which after a stop-on-error halt
965
+ # is the failure itself rather than the step after it, and is omitted only when the
966
+ # whole board finished. The point is a position in execution order, as --from reads it.
967
+ if checkpointer is None or not steps:
968
+ return None
969
+ done = {step.index for step in steps if step.status == "completed"}
970
+ if any(step.status == "failed" for step in steps):
971
+ return checkpointer.resume_command(0, repair=True)
972
+ order = self._order(settings)
973
+ following = next(
974
+ (rank for rank, index in enumerate(order) if index not in done), len(order)
975
+ )
976
+ if following >= len(order):
977
+ return None
978
+ return checkpointer.resume_command(following)
979
+
980
+ def _prompt(self) -> str:
981
+ # Loads the fixed stage prompt for every board task agent.
982
+ return files(__package__).joinpath("task_board_system.md").read_text(encoding="utf-8")