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.
- vidbyte_cli/__init__.py +11 -0
- vidbyte_cli/__main__.py +6 -0
- vidbyte_cli/cli.py +18 -0
- vidbyte_cli/commands/__init__.py +86 -0
- vidbyte_cli/commands/agent_options.py +53 -0
- vidbyte_cli/commands/agents/__init__.py +31 -0
- vidbyte_cli/commands/agents/suggestion/__init__.py +38 -0
- vidbyte_cli/commands/agents/suggestion/prompts/__init__.py +3 -0
- vidbyte_cli/commands/agents/suggestion/prompts/all_categories.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/approaches.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/avoid.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/blockers.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/capability.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/categories.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/category.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/completed.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/constraint.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/context.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/count.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/critic_model.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/decision.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/dry_run.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/extra_compute.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_accept.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_group.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_project.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_reason.md +98 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_reject.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_suggestion.md +101 -0
- vidbyte_cli/commands/agents/suggestion/prompts/files.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/forbidden.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/goal.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/handoff.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/horizon.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/hypotheses.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/idea.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/in_progress.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/input.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/library.py +29 -0
- vidbyte_cli/commands/agents/suggestion/prompts/max_messages.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/max_output_tokens.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/max_total_tokens.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/mistakes.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/outcomes.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_create.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_description.md +98 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_group.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_key.md +96 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_list.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_title.md +99 -0
- vidbyte_cli/commands/agents/suggestion/prompts/provider.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/question.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/risks.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/rounds.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/run.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/success.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/timeout.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/trajectory.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/view.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/view_all.md +5 -0
- vidbyte_cli/commands/agents/suggestion/render.py +77 -0
- vidbyte_cli/commands/agents/suggestion/request_builder.py +240 -0
- vidbyte_cli/commands/agents/suggestion/suggest.py +154 -0
- vidbyte_cli/commands/agents/suggestion/suggestion_categories.py +77 -0
- vidbyte_cli/commands/agents/suggestion/suggestion_feedback.py +85 -0
- vidbyte_cli/commands/agents/suggestion/suggestion_handoff.py +67 -0
- vidbyte_cli/commands/agents/suggestion/suggestion_projects.py +95 -0
- vidbyte_cli/commands/auth/__init__.py +0 -0
- vidbyte_cli/commands/auth/login.py +100 -0
- vidbyte_cli/commands/auth/logout.py +41 -0
- vidbyte_cli/commands/auth/whoami.py +51 -0
- vidbyte_cli/commands/billing/__init__.py +20 -0
- vidbyte_cli/commands/billing/prompts/__init__.py +1 -0
- vidbyte_cli/commands/billing/prompts/confirm.md +16 -0
- vidbyte_cli/commands/billing/prompts/group.md +13 -0
- vidbyte_cli/commands/billing/prompts/idempotency_key.md +16 -0
- vidbyte_cli/commands/billing/prompts/library.py +18 -0
- vidbyte_cli/commands/billing/prompts/top_up.md +16 -0
- vidbyte_cli/commands/billing/top_up.py +65 -0
- vidbyte_cli/commands/config/__init__.py +0 -0
- vidbyte_cli/commands/config/get.py +45 -0
- vidbyte_cli/commands/config/set.py +39 -0
- vidbyte_cli/commands/provider/__init__.py +9 -0
- vidbyte_cli/commands/provider/login.py +102 -0
- vidbyte_cli/commands/provider/logout.py +45 -0
- vidbyte_cli/commands/provider/whoami.py +49 -0
- vidbyte_cli/commands/research/__init__.py +5 -0
- vidbyte_cli/commands/research/add.py +41 -0
- vidbyte_cli/commands/research/options.py +134 -0
- vidbyte_cli/commands/research/render.py +157 -0
- vidbyte_cli/commands/research/resume.py +37 -0
- vidbyte_cli/commands/research/start.py +37 -0
- vidbyte_cli/commands/research/status.py +32 -0
- vidbyte_cli/commands/research/thread.py +30 -0
- vidbyte_cli/commands/research/threads.py +35 -0
- vidbyte_cli/commands/research/watch.py +93 -0
- vidbyte_cli/commands/runtime/__init__.py +23 -0
- vidbyte_cli/commands/runtime/adversarial_team.py +41 -0
- vidbyte_cli/commands/runtime/doctor.py +44 -0
- vidbyte_cli/commands/runtime/list.py +47 -0
- vidbyte_cli/commands/runtime/persistence.py +141 -0
- vidbyte_cli/commands/runtime/same_host_ensemble.py +221 -0
- vidbyte_cli/commands/runtime/stages.py +553 -0
- vidbyte_cli/commands/runtime/task_board.py +1342 -0
- vidbyte_cli/commands/setup/__init__.py +0 -0
- vidbyte_cli/commands/setup/doctor.py +64 -0
- vidbyte_cli/commands/setup/prompts/__init__.py +1 -0
- vidbyte_cli/commands/setup/prompts/browser.md +7 -0
- vidbyte_cli/commands/setup/prompts/library.py +18 -0
- vidbyte_cli/commands/setup/prompts/star.md +13 -0
- vidbyte_cli/commands/setup/star.py +132 -0
- vidbyte_cli/lib/__init__.py +0 -0
- vidbyte_cli/lib/api/__init__.py +0 -0
- vidbyte_cli/lib/api/client.py +236 -0
- vidbyte_cli/lib/api/endpoints/__init__.py +0 -0
- vidbyte_cli/lib/api/endpoints/auth.py +24 -0
- vidbyte_cli/lib/api/endpoints/billing.py +36 -0
- vidbyte_cli/lib/api/endpoints/research.py +103 -0
- vidbyte_cli/lib/api/endpoints/runtime.py +113 -0
- vidbyte_cli/lib/api/problem.py +86 -0
- vidbyte_cli/lib/api/response.py +101 -0
- vidbyte_cli/lib/api/retry.py +116 -0
- vidbyte_cli/lib/api/runtime_payment.py +89 -0
- vidbyte_cli/lib/auth/__init__.py +16 -0
- vidbyte_cli/lib/auth/credentials.py +223 -0
- vidbyte_cli/lib/auth/input.py +59 -0
- vidbyte_cli/lib/auth/keyring_store.py +126 -0
- vidbyte_cli/lib/auth/provider_credentials.py +157 -0
- vidbyte_cli/lib/auth/provider_input.py +54 -0
- vidbyte_cli/lib/auth/provider_resolver.py +89 -0
- vidbyte_cli/lib/auth/provider_store.py +149 -0
- vidbyte_cli/lib/auth/provider_verifier.py +219 -0
- vidbyte_cli/lib/auth/resolver.py +63 -0
- vidbyte_cli/lib/auth/verifier.py +38 -0
- vidbyte_cli/lib/config/__init__.py +25 -0
- vidbyte_cli/lib/config/atomic.py +47 -0
- vidbyte_cli/lib/config/config.py +141 -0
- vidbyte_cli/lib/config/migration.py +81 -0
- vidbyte_cli/lib/config/models.py +145 -0
- vidbyte_cli/lib/config/paths.py +66 -0
- vidbyte_cli/lib/config/resolver.py +137 -0
- vidbyte_cli/lib/constants/__init__.py +1 -0
- vidbyte_cli/lib/constants/runtime.py +186 -0
- vidbyte_cli/lib/errors/__init__.py +12 -0
- vidbyte_cli/lib/errors/cli_error.py +86 -0
- vidbyte_cli/lib/errors/codes.py +43 -0
- vidbyte_cli/lib/errors/failures.py +2910 -0
- vidbyte_cli/lib/errors/handler.py +72 -0
- vidbyte_cli/lib/files/__init__.py +6 -0
- vidbyte_cli/lib/files/documents.py +94 -0
- vidbyte_cli/lib/files/store.py +156 -0
- vidbyte_cli/lib/io/__init__.py +16 -0
- vidbyte_cli/lib/io/attachments.py +129 -0
- vidbyte_cli/lib/io/codex_attachments.py +47 -0
- vidbyte_cli/lib/io/prompt.py +71 -0
- vidbyte_cli/lib/io/streams.py +37 -0
- vidbyte_cli/lib/io/terminal.py +66 -0
- vidbyte_cli/lib/output/__init__.py +7 -0
- vidbyte_cli/lib/output/formats.py +29 -0
- vidbyte_cli/lib/output/manager.py +95 -0
- vidbyte_cli/lib/output/models.py +49 -0
- vidbyte_cli/lib/runtime/__init__.py +9 -0
- vidbyte_cli/lib/runtime/application.py +134 -0
- vidbyte_cli/lib/runtime/context.py +250 -0
- vidbyte_cli/lib/runtime/options.py +159 -0
- vidbyte_cli/lib/runtime/version.py +30 -0
- vidbyte_cli/lib/runtime_primitives/__init__.py +28 -0
- vidbyte_cli/lib/runtime_primitives/executor.py +54 -0
- vidbyte_cli/lib/runtime_primitives/gate.py +105 -0
- vidbyte_cli/lib/runtime_primitives/hosts.py +45 -0
- vidbyte_cli/lib/runtime_primitives/planner.py +79 -0
- vidbyte_cli/lib/runtime_primitives/stages.py +338 -0
- vidbyte_cli/lib/runtime_primitives/task_board.py +982 -0
- vidbyte_cli/lib/runtime_primitives/task_board_checkpoints.py +473 -0
- vidbyte_cli/lib/runtime_primitives/task_board_system.md +33 -0
- vidbyte_cli/lib/runtime_primitives/task_board_tasks.py +111 -0
- vidbyte_cli/lib/runtime_primitives/verification.py +68 -0
- vidbyte_cli/services/__init__.py +5 -0
- vidbyte_cli/services/ensemble/__init__.py +7 -0
- vidbyte_cli/services/ensemble/prompts/__init__.py +6 -0
- vidbyte_cli/services/ensemble/prompts/implementer_system.md +546 -0
- vidbyte_cli/services/ensemble/prompts/implementer_turn.md +49 -0
- vidbyte_cli/services/ensemble/prompts/library.py +188 -0
- vidbyte_cli/services/ensemble/prompts/planner_system.md +728 -0
- vidbyte_cli/services/ensemble/prompts/planner_turn.md +6 -0
- vidbyte_cli/services/ensemble/prompts/role_system.md +104 -0
- vidbyte_cli/services/ensemble/prompts/role_turn.md +19 -0
- vidbyte_cli/services/ensemble/prompts/selector_final_turn.md +19 -0
- vidbyte_cli/services/ensemble/prompts/selector_round_turn.md +17 -0
- vidbyte_cli/services/ensemble/prompts/selector_system.md +89 -0
- vidbyte_cli/services/ensemble/runner.py +30 -0
- vidbyte_cli/services/ensemble/sdk.py +134 -0
- vidbyte_cli/services/ensemble/service.py +233 -0
- vidbyte_cli/services/ensemble/settings.py +60 -0
- vidbyte_cli/services/persistence/__init__.py +6 -0
- vidbyte_cli/services/persistence/prompts/__init__.py +6 -0
- vidbyte_cli/services/persistence/prompts/library.py +46 -0
- vidbyte_cli/services/persistence/prompts/persistence_system.md +1 -0
- vidbyte_cli/services/persistence/prompts/persistence_turn.md +3 -0
- vidbyte_cli/services/persistence/runner.py +35 -0
- vidbyte_cli/services/persistence/session.py +116 -0
- vidbyte_cli/services/suggestions/__init__.py +7 -0
- vidbyte_cli/services/suggestions/categories.py +270 -0
- vidbyte_cli/services/suggestions/context.py +222 -0
- vidbyte_cli/services/suggestions/extra_compute.py +53 -0
- vidbyte_cli/services/suggestions/handoff.py +173 -0
- vidbyte_cli/services/suggestions/message_tools.py +47 -0
- vidbyte_cli/services/suggestions/project.py +253 -0
- vidbyte_cli/services/suggestions/prompts/__init__.py +1 -0
- vidbyte_cli/services/suggestions/prompts/categories/__init__.py +3 -0
- vidbyte_cli/services/suggestions/prompts/categories/adjacent_opportunity.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/alternative.md +38 -0
- vidbyte_cli/services/suggestions/prompts/categories/big_bets.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/bottleneck.md +38 -0
- vidbyte_cli/services/suggestions/prompts/categories/business_growth.md +38 -0
- vidbyte_cli/services/suggestions/prompts/categories/completion.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/continuation.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/coordination.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/creative_exploration.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/cross_domain.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/delegation.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/experiment.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/feedback.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/goal_clarification.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/immediate_next_steps.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/investigation.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/learning.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/leverage.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/long_term_directions.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/preparation.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/prerequisite.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/prioritization.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/product_experience.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/quick_wins.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/reframing.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/risk_prevention.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/simplification.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/stop_or_defer.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/strategy.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/technical_possibilities.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/verification.md +37 -0
- vidbyte_cli/services/suggestions/prompts/critic.md +209 -0
- vidbyte_cli/services/suggestions/prompts/critic_message.md +8 -0
- vidbyte_cli/services/suggestions/prompts/generator.md +64 -0
- vidbyte_cli/services/suggestions/prompts/library.py +72 -0
- vidbyte_cli/services/suggestions/prompts/message_generator_argument.md +1 -0
- vidbyte_cli/services/suggestions/prompts/message_generator_tool.md +1 -0
- vidbyte_cli/services/suggestions/prompts/message_parent_argument.md +1 -0
- vidbyte_cli/services/suggestions/prompts/message_parent_tool.md +1 -0
- vidbyte_cli/services/suggestions/prompts/message_receipt.md +1 -0
- vidbyte_cli/services/suggestions/prompts/revision.md +8 -0
- vidbyte_cli/services/suggestions/sdk.py +228 -0
- vidbyte_cli/services/suggestions/selection.py +78 -0
- vidbyte_cli/services/suggestions/service.py +540 -0
- vidbyte_cli/types/__init__.py +0 -0
- vidbyte_cli/types/api.py +101 -0
- vidbyte_cli/types/attachments.py +79 -0
- vidbyte_cli/types/ensemble.py +189 -0
- vidbyte_cli/types/provider.py +62 -0
- vidbyte_cli/types/research.py +260 -0
- vidbyte_cli/types/runtime.py +1098 -0
- vidbyte_cli/types/suggestions.py +518 -0
- vidbyte_cli-0.1.0.dist-info/METADATA +335 -0
- vidbyte_cli-0.1.0.dist-info/RECORD +269 -0
- vidbyte_cli-0.1.0.dist-info/WHEEL +5 -0
- vidbyte_cli-0.1.0.dist-info/entry_points.txt +2 -0
- vidbyte_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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")
|