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,79 @@
|
|
|
1
|
+
"""Provider-neutral contracts for explicit files supplied to future agent invocations.
|
|
2
|
+
|
|
3
|
+
These models describe one immutable snapshot and its body-free output manifest. Filesystem
|
|
4
|
+
access and provider-specific conversion belong to the I/O layer, not to these contracts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AttachmentKind(StrEnum):
|
|
16
|
+
"""The two file forms the first shared attachment contract supports."""
|
|
17
|
+
|
|
18
|
+
TEXT = "text"
|
|
19
|
+
IMAGE = "image"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AgentAttachment(BaseModel):
|
|
23
|
+
"""One validated file snapshot with provenance and integrity metadata."""
|
|
24
|
+
|
|
25
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
26
|
+
|
|
27
|
+
supplied_path: Path
|
|
28
|
+
resolved_path: Path
|
|
29
|
+
name: str = Field(min_length=1, max_length=256)
|
|
30
|
+
kind: AttachmentKind
|
|
31
|
+
size_bytes: int = Field(gt=0)
|
|
32
|
+
sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
33
|
+
content: str | None = None
|
|
34
|
+
|
|
35
|
+
@model_validator(mode="after")
|
|
36
|
+
def validate_content_shape(self) -> AgentAttachment:
|
|
37
|
+
# Images use the native path input; text attachments carry the captured body.
|
|
38
|
+
if self.kind is AttachmentKind.TEXT and not self.content:
|
|
39
|
+
raise ValueError("text attachments require content")
|
|
40
|
+
if self.kind is AttachmentKind.IMAGE and self.content is not None:
|
|
41
|
+
raise ValueError("image attachments cannot carry text content")
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AttachmentBundle(BaseModel):
|
|
46
|
+
"""An ordered immutable attachment collection resolved for one invocation."""
|
|
47
|
+
|
|
48
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
49
|
+
|
|
50
|
+
items: tuple[AgentAttachment, ...] = ()
|
|
51
|
+
total_bytes: int = Field(default=0, ge=0)
|
|
52
|
+
|
|
53
|
+
@model_validator(mode="after")
|
|
54
|
+
def validate_totals_and_paths(self) -> AttachmentBundle:
|
|
55
|
+
# The bundle owns the aggregate invariants so programmatic callers cannot bypass limits.
|
|
56
|
+
sizes = sum(item.size_bytes for item in self.items)
|
|
57
|
+
if sizes != self.total_bytes:
|
|
58
|
+
raise ValueError("attachment total_bytes must equal the sum of item sizes")
|
|
59
|
+
paths = tuple(item.resolved_path for item in self.items)
|
|
60
|
+
if len(set(paths)) != len(paths):
|
|
61
|
+
raise ValueError("attachment resolved paths must be unique")
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
def manifest(self) -> tuple[dict[str, object], ...]:
|
|
65
|
+
# Emits safe metadata only; attachment bodies never enter machine-facing manifests.
|
|
66
|
+
return tuple(
|
|
67
|
+
{
|
|
68
|
+
"supplied_path": str(item.supplied_path),
|
|
69
|
+
"resolved_path": str(item.resolved_path),
|
|
70
|
+
"name": item.name,
|
|
71
|
+
"kind": item.kind.value,
|
|
72
|
+
"size_bytes": item.size_bytes,
|
|
73
|
+
"sha256": item.sha256,
|
|
74
|
+
}
|
|
75
|
+
for item in self.items
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = ["AgentAttachment", "AttachmentBundle", "AttachmentKind"]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Typed contracts for the same-host ensemble runtime primitive.
|
|
2
|
+
|
|
3
|
+
`EnsembleInputs` is the whole command surface as one validated value, so a bound holds
|
|
4
|
+
whether the value came from Click or from a programmatic caller. `RolePlan`, `RoleProposal`,
|
|
5
|
+
and `SelectionRound` are the three structured-output schemas the agents themselves fill in;
|
|
6
|
+
each is the only shape its stage can return, which is what makes the handoffs deterministic.
|
|
7
|
+
|
|
8
|
+
Nothing here crosses the API boundary: the backend sees only admission metadata.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
17
|
+
|
|
18
|
+
# The proposal band every role is held to, named once so the schema and the authored mandate
|
|
19
|
+
# that tells the role about it cannot drift apart.
|
|
20
|
+
APPROACHES_PER_ROLE_MIN = 5
|
|
21
|
+
APPROACHES_PER_ROLE_MAX = 10
|
|
22
|
+
|
|
23
|
+
# The largest roster the planner may be asked for, which also bounds RolePlan's own schema.
|
|
24
|
+
ROLES_MIN = 3
|
|
25
|
+
ROLES_MAX = 100
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EnsembleHost(StrEnum):
|
|
29
|
+
"""Native hosts the ensemble can actually run on today."""
|
|
30
|
+
|
|
31
|
+
CODEX = "codex"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EnsembleReasoningEffort(StrEnum):
|
|
35
|
+
"""Reasoning-effort values the Codex integration accepts."""
|
|
36
|
+
|
|
37
|
+
NONE = "none"
|
|
38
|
+
MINIMAL = "minimal"
|
|
39
|
+
LOW = "low"
|
|
40
|
+
MEDIUM = "medium"
|
|
41
|
+
HIGH = "high"
|
|
42
|
+
XHIGH = "xhigh"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EnsembleConfidence(StrEnum):
|
|
46
|
+
"""How strongly a role stands behind one approach it proposed."""
|
|
47
|
+
|
|
48
|
+
LOW = "low"
|
|
49
|
+
MEDIUM = "medium"
|
|
50
|
+
HIGH = "high"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class EnsembleInputs(BaseModel):
|
|
54
|
+
"""Every caller-settable ensemble option, validated once as a single value."""
|
|
55
|
+
|
|
56
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
57
|
+
task: str = Field(min_length=1, max_length=20_000)
|
|
58
|
+
host: EnsembleHost = EnsembleHost.CODEX
|
|
59
|
+
roles: int = Field(default=3, ge=ROLES_MIN, le=ROLES_MAX)
|
|
60
|
+
model: str | None = Field(default=None, max_length=128)
|
|
61
|
+
reasoning_effort: EnsembleReasoningEffort | None = None
|
|
62
|
+
role_timeout_seconds: int = Field(default=300, ge=1, le=3600)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class GeneratedRole(BaseModel):
|
|
66
|
+
"""One role the planner invented, as the six sections its prompt is built from."""
|
|
67
|
+
|
|
68
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
69
|
+
name: str = Field(min_length=1, max_length=64)
|
|
70
|
+
identity: str = Field(min_length=1, max_length=4_000)
|
|
71
|
+
personality: str = Field(min_length=1, max_length=4_000)
|
|
72
|
+
expertise: str = Field(min_length=1, max_length=8_000)
|
|
73
|
+
knowledge: str = Field(min_length=1, max_length=8_000)
|
|
74
|
+
skills: str = Field(min_length=1, max_length=8_000)
|
|
75
|
+
goal: str = Field(min_length=1, max_length=4_000)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RolePlan(BaseModel):
|
|
79
|
+
"""The planner turn's structured output: the ensemble's whole roster."""
|
|
80
|
+
|
|
81
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
82
|
+
task_analysis: str = Field(min_length=1, max_length=8_000)
|
|
83
|
+
roster_strategy: str = Field(min_length=1, max_length=8_000)
|
|
84
|
+
coverage_summary: str = Field(min_length=1, max_length=8_000)
|
|
85
|
+
roles: tuple[GeneratedRole, ...] = Field(min_length=1, max_length=ROLES_MAX)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ProposedApproach(BaseModel):
|
|
89
|
+
"""One candidate way to do the task, weighed by the role that thought of it."""
|
|
90
|
+
|
|
91
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
92
|
+
title: str = Field(min_length=1, max_length=120)
|
|
93
|
+
approach: str = Field(min_length=1, max_length=8_000)
|
|
94
|
+
pros: tuple[str, ...] = Field(min_length=1, max_length=10)
|
|
95
|
+
cons: tuple[str, ...] = Field(min_length=1, max_length=10)
|
|
96
|
+
risks: tuple[str, ...] = Field(default=(), max_length=10)
|
|
97
|
+
files: tuple[str, ...] = Field(default=(), max_length=50)
|
|
98
|
+
confidence: EnsembleConfidence
|
|
99
|
+
assumptions: tuple[str, ...] = Field(min_length=1, max_length=20)
|
|
100
|
+
tradeoffs: tuple[str, ...] = Field(min_length=1, max_length=20)
|
|
101
|
+
validation_steps: tuple[str, ...] = Field(min_length=1, max_length=20)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class RoleProposal(BaseModel):
|
|
105
|
+
"""One read-only role's whole slate of candidate approaches, never an edit."""
|
|
106
|
+
|
|
107
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
108
|
+
role: str = Field(min_length=1, max_length=64)
|
|
109
|
+
task_analysis: str = Field(min_length=1, max_length=8_000)
|
|
110
|
+
role_strategy: str = Field(min_length=1, max_length=8_000)
|
|
111
|
+
approaches: tuple[ProposedApproach, ...] = Field(
|
|
112
|
+
min_length=APPROACHES_PER_ROLE_MIN, max_length=APPROACHES_PER_ROLE_MAX
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class CandidateVerdict(BaseModel):
|
|
117
|
+
"""The selector's weighing of one candidate it chose to keep in a round."""
|
|
118
|
+
|
|
119
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
120
|
+
candidate_id: str = Field(min_length=1, max_length=32)
|
|
121
|
+
pros: tuple[str, ...] = Field(min_length=1, max_length=10)
|
|
122
|
+
cons: tuple[str, ...] = Field(min_length=1, max_length=10)
|
|
123
|
+
score: int = Field(ge=1, le=100)
|
|
124
|
+
rationale: str = Field(min_length=1, max_length=4_000)
|
|
125
|
+
evidence: tuple[str, ...] = Field(min_length=1, max_length=20)
|
|
126
|
+
uncertainties: tuple[str, ...] = Field(default=(), max_length=20)
|
|
127
|
+
implementation_guidance: str = Field(min_length=1, max_length=8_000)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class SelectionRound(BaseModel):
|
|
131
|
+
"""One narrowing round's structured output: who survived it, and who did not."""
|
|
132
|
+
|
|
133
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
134
|
+
round_summary: str = Field(min_length=1, max_length=8_000)
|
|
135
|
+
comparison_basis: tuple[str, ...] = Field(min_length=1, max_length=20)
|
|
136
|
+
kept: tuple[CandidateVerdict, ...] = Field(min_length=1, max_length=1_000)
|
|
137
|
+
eliminated: tuple[str, ...] = Field(default=(), max_length=1_000)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ApproachCandidate(BaseModel):
|
|
141
|
+
"""One numbered approach on the selector's slate, joined to the role that proposed it.
|
|
142
|
+
|
|
143
|
+
The id is assigned by the service rather than by any agent, so the identifiers the
|
|
144
|
+
selector echoes back can be checked against the slate it was actually given.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
148
|
+
candidate_id: str
|
|
149
|
+
role: str
|
|
150
|
+
task_analysis: str
|
|
151
|
+
role_strategy: str
|
|
152
|
+
approach: ProposedApproach
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class SelectedApproach(BaseModel):
|
|
156
|
+
"""The single approach the selector ended on, with the verdict that chose it."""
|
|
157
|
+
|
|
158
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
159
|
+
candidate: ApproachCandidate
|
|
160
|
+
verdict: CandidateVerdict
|
|
161
|
+
round_summary: str
|
|
162
|
+
comparison_basis: tuple[str, ...]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class EnsembleRoleFailure(BaseModel):
|
|
166
|
+
"""Why one role produced no proposal, in a closed machine-branchable vocabulary."""
|
|
167
|
+
|
|
168
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
169
|
+
role: str = Field(min_length=1, max_length=64)
|
|
170
|
+
reason: Literal["timeout", "host_error", "invalid_output"]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class EnsembleResult(BaseModel):
|
|
174
|
+
"""The complete outcome of one ensemble run, including its partial failures."""
|
|
175
|
+
|
|
176
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
177
|
+
task: str
|
|
178
|
+
host: EnsembleHost
|
|
179
|
+
roles: tuple[GeneratedRole, ...]
|
|
180
|
+
proposals: tuple[RoleProposal, ...]
|
|
181
|
+
failures: tuple[EnsembleRoleFailure, ...]
|
|
182
|
+
candidates: int
|
|
183
|
+
rounds: tuple[SelectionRound, ...]
|
|
184
|
+
selected: SelectedApproach
|
|
185
|
+
implementation: str
|
|
186
|
+
root_thread_id: str
|
|
187
|
+
selector_thread_id: str
|
|
188
|
+
implementer_thread_id: str
|
|
189
|
+
charged_cents: int
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Provider identity for BYOK runtime execution.
|
|
2
|
+
|
|
3
|
+
Defines the closed set of providers a user can bring their own key for,
|
|
4
|
+
plus probe and environment mappings. No networking or storage here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Provider(StrEnum):
|
|
13
|
+
"""Every external provider this CLI can store a key for."""
|
|
14
|
+
|
|
15
|
+
OPENAI = "openai"
|
|
16
|
+
CLAUDE = "claude"
|
|
17
|
+
GROK = "grok"
|
|
18
|
+
DEEPSEEK = "deepseek"
|
|
19
|
+
GLM = "glm"
|
|
20
|
+
MUSE = "muse"
|
|
21
|
+
GEMINI = "gemini"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
PROVIDER_KEY_PREFIXES: dict[Provider, tuple[str, ...]] = {
|
|
25
|
+
Provider.OPENAI: ("sk-",),
|
|
26
|
+
Provider.CLAUDE: ("sk-ant-",),
|
|
27
|
+
Provider.GROK: (),
|
|
28
|
+
Provider.DEEPSEEK: ("sk-",),
|
|
29
|
+
Provider.GLM: (),
|
|
30
|
+
Provider.MUSE: ("LLM|",),
|
|
31
|
+
Provider.GEMINI: ("AIza",),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
PROVIDER_ENV_VARS: dict[Provider, str] = {
|
|
35
|
+
Provider.OPENAI: "OPENAI_API_KEY",
|
|
36
|
+
Provider.CLAUDE: "ANTHROPIC_API_KEY",
|
|
37
|
+
Provider.GROK: "XAI_API_KEY",
|
|
38
|
+
Provider.DEEPSEEK: "DEEPSEEK_API_KEY",
|
|
39
|
+
Provider.GLM: "ZAI_API_KEY",
|
|
40
|
+
Provider.MUSE: "MODEL_API_KEY",
|
|
41
|
+
Provider.GEMINI: "GEMINI_API_KEY",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
PROVIDER_DISPLAY: dict[Provider, str] = {
|
|
45
|
+
Provider.OPENAI: "OpenAI",
|
|
46
|
+
Provider.CLAUDE: "Claude",
|
|
47
|
+
Provider.GROK: "Grok",
|
|
48
|
+
Provider.DEEPSEEK: "DeepSeek",
|
|
49
|
+
Provider.GLM: "GLM",
|
|
50
|
+
Provider.MUSE: "Muse",
|
|
51
|
+
Provider.GEMINI: "Gemini",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
PROVIDER_PROBE_URLS: dict[Provider, str] = {
|
|
55
|
+
Provider.OPENAI: "https://api.openai.com/v1/models",
|
|
56
|
+
Provider.CLAUDE: "https://api.anthropic.com/v1/models",
|
|
57
|
+
Provider.GROK: "https://api.x.ai/v1/models",
|
|
58
|
+
Provider.DEEPSEEK: "https://api.deepseek.com/v1/models",
|
|
59
|
+
Provider.GLM: "https://api.z.ai/api/paas/v4/models",
|
|
60
|
+
Provider.MUSE: "https://api.meta.ai/v1/models",
|
|
61
|
+
Provider.GEMINI: "https://generativelanguage.googleapis.com/v1beta/models",
|
|
62
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""The research surface's wire shapes, its status vocabulary, and its two identifier guards.
|
|
2
|
+
|
|
3
|
+
A thread has two identifiers and the API publishes only one of them. `encrypted_id` is the
|
|
4
|
+
public share token every route accepts in a path; `thread_id` is internal and is rejected by
|
|
5
|
+
path validation before any lookup. So the models here bind the CLI's `thread_id` field to the
|
|
6
|
+
wire key `encrypted_id` by alias, deliberately leave `populate_by_name` off so the internal
|
|
7
|
+
key cannot populate it, and set `extra="ignore"` so it is discarded on arrival instead.
|
|
8
|
+
|
|
9
|
+
`ResearchRunStatus` declares no thread field at all, because the status route carries only
|
|
10
|
+
the internal identifier — a field the CLI must never print, since a user would paste it back.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from datetime import date, datetime
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
from typing import Annotated, Literal
|
|
19
|
+
from uuid import uuid4
|
|
20
|
+
|
|
21
|
+
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
22
|
+
|
|
23
|
+
from ..lib.errors.failures import ResearchIdempotencyKeyInvalid, ResearchThreadIdInvalid
|
|
24
|
+
|
|
25
|
+
# The backend pins a thread share token to a UUIDv4 and matches path segments against it in
|
|
26
|
+
# transport, so the same shape is checked locally before a request is ever spent.
|
|
27
|
+
_THREAD_ID_PATTERN = re.compile(
|
|
28
|
+
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"
|
|
29
|
+
)
|
|
30
|
+
_IDEMPOTENCY_KEY_PATTERN = re.compile(r"^[A-Za-z0-9._:-]{8,128}$")
|
|
31
|
+
_MAX_PROMPT_CHARACTERS = 20_000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ResearchStatus(StrEnum):
|
|
35
|
+
"""Every durable state a research run can report."""
|
|
36
|
+
|
|
37
|
+
ADMITTING = "admitting"
|
|
38
|
+
ACCEPTED = "accepted"
|
|
39
|
+
RUNNING = "running"
|
|
40
|
+
COMPLETED = "completed"
|
|
41
|
+
PARTIAL = "partial"
|
|
42
|
+
FAILED = "failed"
|
|
43
|
+
CANCELLED = "cancelled"
|
|
44
|
+
CREDIT_EXHAUSTED = "credit_exhausted"
|
|
45
|
+
|
|
46
|
+
def is_terminal(self) -> bool:
|
|
47
|
+
# True once the backend will not move this run again without a continuation.
|
|
48
|
+
return self in _TERMINAL_STATUSES
|
|
49
|
+
|
|
50
|
+
def is_resumable(self) -> bool:
|
|
51
|
+
# The three states the backend accepts a continuation from; anything else is a 409.
|
|
52
|
+
return self in _RESUMABLE_STATUSES
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_TERMINAL_STATUSES = frozenset(
|
|
56
|
+
{
|
|
57
|
+
ResearchStatus.COMPLETED,
|
|
58
|
+
ResearchStatus.PARTIAL,
|
|
59
|
+
ResearchStatus.FAILED,
|
|
60
|
+
ResearchStatus.CANCELLED,
|
|
61
|
+
ResearchStatus.CREDIT_EXHAUSTED,
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
_RESUMABLE_STATUSES = frozenset(
|
|
65
|
+
{ResearchStatus.PARTIAL, ResearchStatus.FAILED, ResearchStatus.CREDIT_EXHAUSTED}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ResearchSize(StrEnum):
|
|
70
|
+
"""Preset breadth for a run, when the caller does not size it by hand."""
|
|
71
|
+
|
|
72
|
+
SMALL = "small"
|
|
73
|
+
MEDIUM = "medium"
|
|
74
|
+
LARGE = "large"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ResearchKind(StrEnum):
|
|
78
|
+
"""The resource kinds discovery is allowed to return."""
|
|
79
|
+
|
|
80
|
+
PAPER = "paper"
|
|
81
|
+
WEB = "web"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ThreadId:
|
|
85
|
+
"""One public research thread share token, validated on construction."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, value: str) -> None:
|
|
88
|
+
self.value = value
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def parse(cls, value: str) -> ThreadId:
|
|
92
|
+
# Raises the usage failure locally rather than spending a request on a 422.
|
|
93
|
+
candidate = value.strip()
|
|
94
|
+
if not _THREAD_ID_PATTERN.fullmatch(candidate):
|
|
95
|
+
raise ResearchThreadIdInvalid()
|
|
96
|
+
return cls(candidate)
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def normalize(cls, value: str) -> str:
|
|
100
|
+
# The pydantic entry point: validating and canonicalizing are one operation.
|
|
101
|
+
return str(cls.parse(value))
|
|
102
|
+
|
|
103
|
+
def __str__(self) -> str:
|
|
104
|
+
return self.value
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class IdempotencyKey:
|
|
108
|
+
"""The key that lets one priced mutation be retried without being charged twice."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, value: str) -> None:
|
|
111
|
+
self.value = value
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def create(cls, explicit: str | None) -> IdempotencyKey:
|
|
115
|
+
# A fresh key per invocation, unless the caller supplies one to replay a mutation.
|
|
116
|
+
if explicit is None:
|
|
117
|
+
return cls(str(uuid4()))
|
|
118
|
+
candidate = explicit.strip()
|
|
119
|
+
if not _IDEMPOTENCY_KEY_PATTERN.fullmatch(candidate):
|
|
120
|
+
raise ResearchIdempotencyKeyInvalid()
|
|
121
|
+
return cls(candidate)
|
|
122
|
+
|
|
123
|
+
def __str__(self) -> str:
|
|
124
|
+
return self.value
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
NormalizedThreadId = Annotated[str, AfterValidator(ThreadId.normalize)]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ResearchRunCreateRequest(BaseModel):
|
|
131
|
+
"""The body both `research start` and `research add` submit.
|
|
132
|
+
|
|
133
|
+
Mirrors the backend request DTO field for field. Every optional field defaults to None so
|
|
134
|
+
`exclude_none` drops it from the encoded body: the backend forbids unknown keys and
|
|
135
|
+
requires a non-empty `resource_kinds` when the key is present, so an unset option has to
|
|
136
|
+
be absent rather than null or empty.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
140
|
+
|
|
141
|
+
prompt: str = Field(min_length=1, max_length=_MAX_PROMPT_CHARACTERS)
|
|
142
|
+
# Version 2 is the backend default and the only version that carries reference artifacts.
|
|
143
|
+
request_schema_version: Literal[2] = 2
|
|
144
|
+
size: ResearchSize | None = None
|
|
145
|
+
target_sources: int | None = Field(default=None, ge=1, le=1_000)
|
|
146
|
+
search_calls: int | None = Field(default=None, ge=1, le=100)
|
|
147
|
+
resource_kinds: list[ResearchKind] | None = Field(default=None, min_length=1, max_length=2)
|
|
148
|
+
include_domains: list[str] | None = Field(default=None, max_length=50)
|
|
149
|
+
exclude_domains: list[str] | None = Field(default=None, max_length=50)
|
|
150
|
+
published_after: str | None = Field(default=None, pattern=r"^\d{4}-\d{2}-\d{2}$")
|
|
151
|
+
language: str | None = Field(default=None, min_length=2, max_length=12)
|
|
152
|
+
max_run_cost_cents: int | None = Field(default=None, ge=1, le=100_000)
|
|
153
|
+
|
|
154
|
+
@field_validator("prompt")
|
|
155
|
+
@classmethod
|
|
156
|
+
def normalize_prompt(cls, value: str) -> str:
|
|
157
|
+
normalized = value.strip()
|
|
158
|
+
if not normalized:
|
|
159
|
+
raise ValueError("prompt cannot be blank")
|
|
160
|
+
return normalized
|
|
161
|
+
|
|
162
|
+
@field_validator("published_after")
|
|
163
|
+
@classmethod
|
|
164
|
+
def validate_published_after(cls, value: str | None) -> str | None:
|
|
165
|
+
# The pattern accepts 2025-13-45; the backend does not. Catching it here keeps a
|
|
166
|
+
# calendar typo from costing a request against a priced route.
|
|
167
|
+
if value is not None:
|
|
168
|
+
date.fromisoformat(value)
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
@field_validator("include_domains", "exclude_domains", mode="before")
|
|
172
|
+
@classmethod
|
|
173
|
+
def normalize_domains(cls, value: object) -> object:
|
|
174
|
+
# The backend accepts hostnames only, so a pasted URL is corrected here, not there.
|
|
175
|
+
# Before the length bound, so a list that only exceeds 50 through duplicates passes.
|
|
176
|
+
if not isinstance(value, list):
|
|
177
|
+
return value
|
|
178
|
+
normalized: list[str] = []
|
|
179
|
+
for item in value:
|
|
180
|
+
host = str(item).strip().lower().rstrip(".")
|
|
181
|
+
if not host or "/" in host or ":" in host:
|
|
182
|
+
raise ValueError("domain filters must be hostname-only values")
|
|
183
|
+
if host not in normalized:
|
|
184
|
+
normalized.append(host)
|
|
185
|
+
return normalized
|
|
186
|
+
|
|
187
|
+
@field_validator("resource_kinds", mode="before")
|
|
188
|
+
@classmethod
|
|
189
|
+
def deduplicate_kinds(cls, value: object) -> object:
|
|
190
|
+
# Runs before the length bound: `--kind paper --kind paper --kind web` is three
|
|
191
|
+
# values and two kinds, and rejecting it for length would be a lie about the input.
|
|
192
|
+
if not isinstance(value, list):
|
|
193
|
+
return value
|
|
194
|
+
return list(dict.fromkeys(value))
|
|
195
|
+
|
|
196
|
+
@model_validator(mode="after")
|
|
197
|
+
def reject_contradictory_filters(self) -> ResearchRunCreateRequest:
|
|
198
|
+
# A domain in both lists can never match, so catch it before spending a request.
|
|
199
|
+
if set(self.include_domains or ()) & set(self.exclude_domains or ()):
|
|
200
|
+
raise ValueError("a domain cannot be both included and excluded")
|
|
201
|
+
return self
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class ResearchRunAccepted(BaseModel):
|
|
205
|
+
"""The 202 identity returned by start, add, and resume."""
|
|
206
|
+
|
|
207
|
+
model_config = ConfigDict(extra="ignore", frozen=True)
|
|
208
|
+
|
|
209
|
+
thread_id: str = Field(alias="encrypted_id")
|
|
210
|
+
run_id: str
|
|
211
|
+
status: ResearchStatus
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class ResearchRunStatus(BaseModel):
|
|
215
|
+
"""One run's durable progress.
|
|
216
|
+
|
|
217
|
+
The backend also sends the thread's internal identifier here. It is deliberately not
|
|
218
|
+
declared, so `extra="ignore"` drops it and no renderer can print an ID that would be
|
|
219
|
+
rejected the moment a user pasted it into `research add`.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
model_config = ConfigDict(extra="ignore", frozen=True)
|
|
223
|
+
|
|
224
|
+
run_id: str
|
|
225
|
+
status: ResearchStatus
|
|
226
|
+
phase: str
|
|
227
|
+
continuation_count: int = Field(ge=0)
|
|
228
|
+
updated_at: datetime
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class ResearchThreadSummary(BaseModel):
|
|
232
|
+
"""One row of the caller's thread portfolio."""
|
|
233
|
+
|
|
234
|
+
model_config = ConfigDict(extra="ignore", frozen=True)
|
|
235
|
+
|
|
236
|
+
thread_id: str = Field(alias="encrypted_id")
|
|
237
|
+
title: str
|
|
238
|
+
run_count: int = Field(ge=0)
|
|
239
|
+
source_count: int = Field(ge=0)
|
|
240
|
+
artifact_count: int = Field(ge=0)
|
|
241
|
+
latest_run_id: str | None = None
|
|
242
|
+
latest_run_status: ResearchStatus | None = None
|
|
243
|
+
created_at: datetime
|
|
244
|
+
updated_at: datetime
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class ResearchThreadDetail(ResearchThreadSummary):
|
|
248
|
+
"""One thread read on its own, which carries two fields a portfolio row omits."""
|
|
249
|
+
|
|
250
|
+
latest_run_phase: str | None = None
|
|
251
|
+
favorite: bool = False
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class ResearchPortfolioPage(BaseModel):
|
|
255
|
+
"""One bounded page of threads and the cursor that continues it."""
|
|
256
|
+
|
|
257
|
+
model_config = ConfigDict(extra="ignore", frozen=True)
|
|
258
|
+
|
|
259
|
+
threads: list[ResearchThreadSummary]
|
|
260
|
+
next_cursor: str | None = None
|