whiletrue 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.
- whiletrue/__init__.py +0 -0
- whiletrue/__main__.py +3 -0
- whiletrue/abort.py +79 -0
- whiletrue/basics/__init__.py +99 -0
- whiletrue/basics/agent.py +249 -0
- whiletrue/basics/comments.py +113 -0
- whiletrue/basics/diff_size.py +80 -0
- whiletrue/basics/exec_.py +84 -0
- whiletrue/basics/history.py +117 -0
- whiletrue/basics/human.py +201 -0
- whiletrue/basics/json_source.py +35 -0
- whiletrue/basics/markdown_source.py +55 -0
- whiletrue/basics/process.py +99 -0
- whiletrue/basics/sessions.py +62 -0
- whiletrue/basics/set_.py +29 -0
- whiletrue/basics/shell.py +98 -0
- whiletrue/basics/wake.py +27 -0
- whiletrue/claude_code/__init__.py +58 -0
- whiletrue/claude_code/endpoint.py +143 -0
- whiletrue/claude_code/headless.py +181 -0
- whiletrue/claude_code/interactive.py +344 -0
- whiletrue/claude_code/learnings.py +32 -0
- whiletrue/claude_code/panes.py +164 -0
- whiletrue/claude_code/plugin/.claude-plugin/plugin.json +5 -0
- whiletrue/claude_code/plugin/hook.py +44 -0
- whiletrue/claude_code/plugin/hooks/hooks.json +81 -0
- whiletrue/claude_code/plugin/mcp_server.py +98 -0
- whiletrue/claude_code/session.py +345 -0
- whiletrue/claude_code/transcript.py +123 -0
- whiletrue/cli/__init__.py +15 -0
- whiletrue/cli/__main__.py +3 -0
- whiletrue/cli/app.py +550 -0
- whiletrue/cli/common.py +36 -0
- whiletrue/cli/gates.py +156 -0
- whiletrue/cli/ops.py +339 -0
- whiletrue/cli/panel.py +198 -0
- whiletrue/cli/registry.py +143 -0
- whiletrue/cli/templates.py +24 -0
- whiletrue/codex/__init__.py +14 -0
- whiletrue/codex/runner.py +243 -0
- whiletrue/control/__init__.py +38 -0
- whiletrue/control/chain.py +61 -0
- whiletrue/control/loop.py +139 -0
- whiletrue/control/map_.py +101 -0
- whiletrue/control/parallel.py +32 -0
- whiletrue/control/retry.py +100 -0
- whiletrue/control/sequence.py +40 -0
- whiletrue/control/switch.py +67 -0
- whiletrue/core/__init__.py +260 -0
- whiletrue/core/caps.py +139 -0
- whiletrue/core/comments.py +80 -0
- whiletrue/core/composite.py +140 -0
- whiletrue/core/config.py +461 -0
- whiletrue/core/context.py +177 -0
- whiletrue/core/duration.py +19 -0
- whiletrue/core/events.py +94 -0
- whiletrue/core/expr.py +85 -0
- whiletrue/core/history.py +83 -0
- whiletrue/core/lock.py +86 -0
- whiletrue/core/memory_store.py +352 -0
- whiletrue/core/module.py +146 -0
- whiletrue/core/outcomes.py +82 -0
- whiletrue/core/registry.py +129 -0
- whiletrue/core/replay.py +58 -0
- whiletrue/core/runner.py +98 -0
- whiletrue/core/runtime.py +538 -0
- whiletrue/core/source.py +45 -0
- whiletrue/core/store.py +207 -0
- whiletrue/core/template.py +60 -0
- whiletrue/core/validate.py +142 -0
- whiletrue/core/wake.py +117 -0
- whiletrue/core/workspace.py +55 -0
- whiletrue/git/__init__.py +31 -0
- whiletrue/git/modules.py +128 -0
- whiletrue/git/workspaces.py +77 -0
- whiletrue/github/__init__.py +30 -0
- whiletrue/github/client.py +269 -0
- whiletrue/github/filebacked.py +87 -0
- whiletrue/github/memory.py +96 -0
- whiletrue/github/plugin.py +343 -0
- whiletrue/github/specs.py +39 -0
- whiletrue/opencode/__init__.py +25 -0
- whiletrue/opencode/runner.py +186 -0
- whiletrue/py.typed +0 -0
- whiletrue/recipes/issue-loop.yaml +59 -0
- whiletrue/recipes/ralph.yaml +14 -0
- whiletrue/recipes/review-pass.yaml +27 -0
- whiletrue/recipes/review-respond.yaml +22 -0
- whiletrue/store_sqlite/__init__.py +6 -0
- whiletrue/store_sqlite/migrations.py +132 -0
- whiletrue/store_sqlite/sqlite_store.py +537 -0
- whiletrue/tmux/__init__.py +11 -0
- whiletrue/tmux/controller.py +197 -0
- whiletrue-0.1.0.dist-info/METADATA +48 -0
- whiletrue-0.1.0.dist-info/RECORD +98 -0
- whiletrue-0.1.0.dist-info/WHEEL +4 -0
- whiletrue-0.1.0.dist-info/entry_points.txt +2 -0
- whiletrue-0.1.0.dist-info/licenses/LICENSE +21 -0
whiletrue/core/module.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from whiletrue.core.config import PipelineNode
|
|
12
|
+
from whiletrue.core.context import CompositeContext, StepContext
|
|
13
|
+
from whiletrue.core.registry import Registry
|
|
14
|
+
|
|
15
|
+
#: How a module may touch the world. Drives the safety rules (see architecture.md).
|
|
16
|
+
EFFECTS = ("pure", "deterministic", "agentic", "side-effect")
|
|
17
|
+
Effect = Literal["pure", "deterministic", "agentic", "side-effect"]
|
|
18
|
+
|
|
19
|
+
#: Values arriving on (or leaving from) a module's ports, keyed by port name.
|
|
20
|
+
PortValues = dict[str, Any]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_effect(value: object) -> bool:
|
|
24
|
+
return isinstance(value, str) and value in EFFECTS
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EmptyConfig(BaseModel):
|
|
28
|
+
"""Config for modules that take none."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class PortSpec:
|
|
33
|
+
"""A named, typed connection point. `cap` names a schema (Issue, Diff, Text, Any, ...)."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
cap: str
|
|
37
|
+
optional: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Ports:
|
|
42
|
+
inputs: list[PortSpec] = field(default_factory=lambda: [])
|
|
43
|
+
outputs: list[PortSpec] = field(default_factory=lambda: [])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Emit:
|
|
48
|
+
"""Which port fires, and with what. `value=None` means no value."""
|
|
49
|
+
|
|
50
|
+
port: str
|
|
51
|
+
value: Any = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class ConfigIssue:
|
|
56
|
+
"""A static-validation finding about a config (never raised at run time)."""
|
|
57
|
+
|
|
58
|
+
severity: Literal["error", "warning"]
|
|
59
|
+
message: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ValidateContext:
|
|
64
|
+
"""What static checks may look at (e.g. an agent step loading its prompt file)."""
|
|
65
|
+
|
|
66
|
+
cwd: str
|
|
67
|
+
registry: Registry
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class ActionModule[Cfg: BaseModel]:
|
|
72
|
+
id: str # e.g. 'gate.tests'
|
|
73
|
+
config: type[Cfg]
|
|
74
|
+
ports: Ports
|
|
75
|
+
effects: Effect
|
|
76
|
+
run: Callable[[PortValues, StepContext[Cfg]], Awaitable[Emit]]
|
|
77
|
+
kind: Literal["action"] = "action"
|
|
78
|
+
capabilities: list[str] = field(default_factory=lambda: [])
|
|
79
|
+
#: Whether results of `pure`/`deterministic` runs may be cached by input hash. Modules that
|
|
80
|
+
#: read the workspace (gates, shell) must set this to False.
|
|
81
|
+
cacheable: bool = True
|
|
82
|
+
#: Extra static checks beyond the config schema (e.g. agent limits).
|
|
83
|
+
validate: Callable[[Cfg, ValidateContext], list[ConfigIssue]] | None = None
|
|
84
|
+
#: Required for `side-effect` modules: a deterministic key so resume never repeats the effect.
|
|
85
|
+
idempotency_key: Callable[[PortValues, StepContext[Cfg]], str] | None = None
|
|
86
|
+
#: Record the result under `idempotency_key` so a repeat (resume, a second run) reuses it
|
|
87
|
+
#: instead of repeating the effect. The key must name the *effect*, not the run.
|
|
88
|
+
journal: bool = False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class Strategy[Cfg: BaseModel]:
|
|
93
|
+
id: str # e.g. 'loop'
|
|
94
|
+
config: type[Cfg]
|
|
95
|
+
run: Callable[[list[PipelineNode], PortValues, CompositeContext[Cfg]], Awaitable[Emit]]
|
|
96
|
+
#: Children are chained: each child's `out` feeds the next child's `in`.
|
|
97
|
+
chain: bool = False
|
|
98
|
+
#: Extra static checks beyond the config schema (e.g. loops must be bounded).
|
|
99
|
+
validate: Callable[[Cfg, ValidateContext], list[ConfigIssue]] | None = None
|
|
100
|
+
#: Static checks that need the step's children (e.g. `switch` case targets).
|
|
101
|
+
validate_node: Callable[[Cfg, PipelineNode, ValidateContext], list[ConfigIssue]] | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ModuleDefinitionError(Exception):
|
|
105
|
+
def __init__(self, module_id: str, problem: str) -> None:
|
|
106
|
+
super().__init__(f'Invalid module "{module_id}": {problem}')
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_ID = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _check_id(module_id: str) -> None:
|
|
113
|
+
if not _ID.match(module_id):
|
|
114
|
+
raise ModuleDefinitionError(
|
|
115
|
+
module_id, "id must be lowercase dotted segments, e.g. 'gate.tests'"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _check_ports(module_id: str, direction: str, ports: list[PortSpec]) -> None:
|
|
120
|
+
seen: set[str] = set()
|
|
121
|
+
for port in ports:
|
|
122
|
+
if port.name in seen:
|
|
123
|
+
raise ModuleDefinitionError(module_id, f'duplicate {direction} port "{port.name}"')
|
|
124
|
+
seen.add(port.name)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def define_action[Cfg: BaseModel](mod: ActionModule[Cfg]) -> ActionModule[Cfg]:
|
|
128
|
+
"""Validates an action module's static shape at definition time and returns it unchanged."""
|
|
129
|
+
_check_id(mod.id)
|
|
130
|
+
if str(mod.kind) != "action":
|
|
131
|
+
raise ModuleDefinitionError(mod.id, f"kind must be 'action', got '{mod.kind}'")
|
|
132
|
+
if not is_effect(mod.effects):
|
|
133
|
+
raise ModuleDefinitionError(mod.id, f"unknown effect '{mod.effects}'")
|
|
134
|
+
if mod.id.startswith("gate.") and mod.effects != "deterministic":
|
|
135
|
+
raise ModuleDefinitionError(
|
|
136
|
+
mod.id, "gates must be deterministic: an agent can never grade itself"
|
|
137
|
+
)
|
|
138
|
+
_check_ports(mod.id, "in", mod.ports.inputs)
|
|
139
|
+
_check_ports(mod.id, "out", mod.ports.outputs)
|
|
140
|
+
return mod
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def define_strategy[Cfg: BaseModel](strategy: Strategy[Cfg]) -> Strategy[Cfg]:
|
|
144
|
+
"""Validates a strategy's static shape at definition time and returns it unchanged."""
|
|
145
|
+
_check_id(strategy.id)
|
|
146
|
+
return strategy
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
#: Every module has these outcome ports in addition to its data ports.
|
|
7
|
+
OUTCOME_PORTS = ("ok", "fail", "needs_human", "skip", "rejected")
|
|
8
|
+
|
|
9
|
+
ItemState = Literal["pending", "ready", "running", "parked", "done", "failed", "cancelled"]
|
|
10
|
+
|
|
11
|
+
#: What a composite does with an unwired `fail`.
|
|
12
|
+
OnFail = Literal["park", "drop", "propagate"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class OutcomePolicy:
|
|
17
|
+
on_fail: OnFail = "park"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Resolution:
|
|
22
|
+
action: Literal["continue", "route", "propagate", "exit"]
|
|
23
|
+
target: str | None = None
|
|
24
|
+
port: str | None = None
|
|
25
|
+
state: ItemState | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_outcome_port(port: str) -> bool:
|
|
29
|
+
return port in OUTCOME_PORTS
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def item_state_for(port: str, on_fail: OnFail) -> ItemState:
|
|
33
|
+
"""Terminal item state for an outcome that leaves the pipeline."""
|
|
34
|
+
match port:
|
|
35
|
+
case "ok" | "skip":
|
|
36
|
+
return "done"
|
|
37
|
+
case "fail":
|
|
38
|
+
return "parked" if on_fail == "park" else "failed"
|
|
39
|
+
case _:
|
|
40
|
+
return "parked"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_outcome(port: str, wires: dict[str, str], policy: OutcomePolicy) -> Resolution:
|
|
44
|
+
"""Decide what happens when a child fires `port`. A wired port always routes to its target.
|
|
45
|
+
|
|
46
|
+
Unwired: ok/skip and custom data ports continue; fail/needs_human/rejected follow the policy.
|
|
47
|
+
"""
|
|
48
|
+
target = wires.get(port)
|
|
49
|
+
if target is not None:
|
|
50
|
+
return Resolution("route", target=target)
|
|
51
|
+
if not is_outcome_port(port) or port in ("ok", "skip"):
|
|
52
|
+
return Resolution("continue")
|
|
53
|
+
if policy.on_fail == "propagate":
|
|
54
|
+
return Resolution("propagate", port=port)
|
|
55
|
+
return Resolution("exit", port=port, state=item_state_for(port, policy.on_fail))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class InvalidTransition(Exception):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
#: Where an item may go next. `done` is final; `failed` and `cancelled` can be rerun.
|
|
63
|
+
_TRANSITIONS: dict[ItemState, frozenset[ItemState]] = {
|
|
64
|
+
"pending": frozenset({"ready", "cancelled"}),
|
|
65
|
+
"ready": frozenset({"running", "pending", "cancelled"}),
|
|
66
|
+
"running": frozenset({"parked", "done", "failed", "cancelled"}),
|
|
67
|
+
"parked": frozenset({"running", "ready", "cancelled"}),
|
|
68
|
+
"done": frozenset(),
|
|
69
|
+
"failed": frozenset({"ready", "cancelled"}),
|
|
70
|
+
"cancelled": frozenset({"ready"}),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def can_transition(old: ItemState, new: ItemState) -> bool:
|
|
75
|
+
return new in _TRANSITIONS[old]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def transition(old: ItemState, new: ItemState) -> ItemState:
|
|
79
|
+
"""`new` if the move is allowed, else InvalidTransition."""
|
|
80
|
+
if not can_transition(old, new):
|
|
81
|
+
raise InvalidTransition(f"Invalid item transition {old} -> {new}")
|
|
82
|
+
return new
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Literal, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from whiletrue.core.caps import CapRegistry, create_builtin_caps
|
|
10
|
+
from whiletrue.core.module import ActionModule, Strategy
|
|
11
|
+
from whiletrue.core.runner import Runner
|
|
12
|
+
|
|
13
|
+
ProviderKind = Literal["source", "store", "workspace", "knowledge", "comments", "wake"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RegistryError(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Resolved:
|
|
22
|
+
kind: Literal["action", "strategy"]
|
|
23
|
+
module: Any # ActionModule[Any] | Strategy[Any]; narrowed by `kind`
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PluginApi(Protocol):
|
|
27
|
+
"""The public plugin surface. Built-in modules use exactly this API."""
|
|
28
|
+
|
|
29
|
+
def register_action[Cfg: BaseModel](self, module: ActionModule[Cfg]) -> None: ...
|
|
30
|
+
def register_strategy[Cfg: BaseModel](self, strategy: Strategy[Cfg]) -> None: ...
|
|
31
|
+
def register_runner(self, runner: Runner) -> None: ...
|
|
32
|
+
def register_provider(self, kind: ProviderKind, name: str, provider: Any) -> None: ...
|
|
33
|
+
def register_cap(self, name: str, schema: Any) -> None: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@runtime_checkable
|
|
37
|
+
class Plugin(Protocol):
|
|
38
|
+
@property
|
|
39
|
+
def name(self) -> str: ...
|
|
40
|
+
|
|
41
|
+
def register(self, api: PluginApi, /) -> None: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class SimplePlugin:
|
|
46
|
+
"""A plugin made of a name and a registration function."""
|
|
47
|
+
|
|
48
|
+
name: str
|
|
49
|
+
register: Callable[[PluginApi], None]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Registry:
|
|
53
|
+
def __init__(self, caps: CapRegistry | None = None) -> None:
|
|
54
|
+
self.caps = caps if caps is not None else create_builtin_caps()
|
|
55
|
+
self._actions: dict[str, ActionModule[Any]] = {}
|
|
56
|
+
self._strategies: dict[str, Strategy[Any]] = {}
|
|
57
|
+
self._runners: dict[str, Runner] = {}
|
|
58
|
+
self._providers: dict[str, Any] = {}
|
|
59
|
+
self._plugins: list[str] = []
|
|
60
|
+
self._plugin_versions: dict[str, str] = {}
|
|
61
|
+
|
|
62
|
+
def _assert_free_id(self, module_id: str) -> None:
|
|
63
|
+
if module_id in self._actions or module_id in self._strategies:
|
|
64
|
+
raise RegistryError(f'Module or strategy "{module_id}" is already registered')
|
|
65
|
+
|
|
66
|
+
def register_action[Cfg: BaseModel](self, module: ActionModule[Cfg]) -> None:
|
|
67
|
+
self._assert_free_id(module.id)
|
|
68
|
+
self._actions[module.id] = module
|
|
69
|
+
|
|
70
|
+
def register_strategy[Cfg: BaseModel](self, strategy: Strategy[Cfg]) -> None:
|
|
71
|
+
self._assert_free_id(strategy.id)
|
|
72
|
+
self._strategies[strategy.id] = strategy
|
|
73
|
+
|
|
74
|
+
def register_runner(self, runner: Runner) -> None:
|
|
75
|
+
if runner.id in self._runners:
|
|
76
|
+
raise RegistryError(f'Runner "{runner.id}" is already registered')
|
|
77
|
+
self._runners[runner.id] = runner
|
|
78
|
+
|
|
79
|
+
def register_provider(self, kind: ProviderKind, name: str, provider: Any) -> None:
|
|
80
|
+
key = f"{kind}:{name}"
|
|
81
|
+
if key in self._providers:
|
|
82
|
+
raise RegistryError(f'Provider "{key}" is already registered')
|
|
83
|
+
self._providers[key] = provider
|
|
84
|
+
|
|
85
|
+
def register_cap(self, name: str, schema: Any) -> None:
|
|
86
|
+
self.caps.register(name, schema)
|
|
87
|
+
|
|
88
|
+
def resolve(self, module_id: str) -> Resolved | None:
|
|
89
|
+
action = self._actions.get(module_id)
|
|
90
|
+
if action is not None:
|
|
91
|
+
return Resolved("action", action)
|
|
92
|
+
strategy = self._strategies.get(module_id)
|
|
93
|
+
if strategy is not None:
|
|
94
|
+
return Resolved("strategy", strategy)
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
def get_runner(self, runner_id: str) -> Runner | None:
|
|
98
|
+
return self._runners.get(runner_id)
|
|
99
|
+
|
|
100
|
+
def get_provider(self, kind: ProviderKind, name: str) -> Any:
|
|
101
|
+
return self._providers.get(f"{kind}:{name}")
|
|
102
|
+
|
|
103
|
+
def providers(self, kind: ProviderKind) -> list[tuple[str, Any]]:
|
|
104
|
+
"""All providers of a kind as (name, provider), in registration order."""
|
|
105
|
+
prefix = f"{kind}:"
|
|
106
|
+
return [
|
|
107
|
+
(key[len(prefix) :], provider)
|
|
108
|
+
for key, provider in self._providers.items()
|
|
109
|
+
if key.startswith(prefix)
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
def ids(self) -> list[str]:
|
|
113
|
+
return sorted([*self._actions, *self._strategies])
|
|
114
|
+
|
|
115
|
+
def use(self, plugin: Plugin, version: str | None = None) -> None:
|
|
116
|
+
"""Installs a plugin. `version` (else its `version` attribute) is what the lock pins."""
|
|
117
|
+
if plugin.name in self._plugins:
|
|
118
|
+
raise RegistryError(f'Plugin "{plugin.name}" is already installed')
|
|
119
|
+
plugin.register(self)
|
|
120
|
+
self._plugins.append(plugin.name)
|
|
121
|
+
self._plugin_versions[plugin.name] = version or str(
|
|
122
|
+
getattr(plugin, "version", "unversioned")
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def plugin_versions(self) -> dict[str, str]:
|
|
126
|
+
return dict(self._plugin_versions)
|
|
127
|
+
|
|
128
|
+
def plugins(self) -> list[str]:
|
|
129
|
+
return list(self._plugins)
|
whiletrue/core/replay.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Replay: re-run a recorded run's logic without running any module that has effects.
|
|
2
|
+
|
|
3
|
+
Every step's port and output is already recorded. A replay serves those recordings for all
|
|
4
|
+
modules except `pure` ones, so control flow, expressions and wiring run for real while agents,
|
|
5
|
+
shells and gates are not executed again.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from whiletrue.core.runtime import RunItem
|
|
14
|
+
from whiletrue.core.store import StateStore
|
|
15
|
+
|
|
16
|
+
#: (item id, node path, attempt) -> recorded (port, output)
|
|
17
|
+
ReplayData = dict[tuple[str, str, int], tuple[str, Any]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Recording:
|
|
22
|
+
run_id: str
|
|
23
|
+
status: str
|
|
24
|
+
pipeline_hash: str
|
|
25
|
+
items: list[RunItem]
|
|
26
|
+
#: Recorded terminal state per item id.
|
|
27
|
+
item_states: dict[str, str]
|
|
28
|
+
steps: ReplayData
|
|
29
|
+
#: The chain of resumes this run belongs to (its own id for a fresh run).
|
|
30
|
+
lineage: str = ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ReplayError(Exception):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_recording(store: StateStore, run_id: str) -> Recording:
|
|
38
|
+
run = store.get_run(run_id)
|
|
39
|
+
if run is None:
|
|
40
|
+
raise ReplayError(f'No run "{run_id}"')
|
|
41
|
+
records = store.list_items(run_id)
|
|
42
|
+
steps: ReplayData = {
|
|
43
|
+
(s.item_id, s.node_path, s.attempt): (s.port, s.output)
|
|
44
|
+
for s in store.list_steps(run_id)
|
|
45
|
+
# A step that parked for a person must run again when its run is resumed.
|
|
46
|
+
if s.port is not None and s.port != "needs_human"
|
|
47
|
+
}
|
|
48
|
+
started = store.list_events(run_id, type="run.started")
|
|
49
|
+
lineage = str(started[0].data.get("lineage", run_id)) if started else run_id
|
|
50
|
+
return Recording(
|
|
51
|
+
run_id=run.id,
|
|
52
|
+
status=run.status,
|
|
53
|
+
pipeline_hash=run.pipeline_hash,
|
|
54
|
+
items=[RunItem(i.id, i.payload) for i in records],
|
|
55
|
+
item_states={i.id: i.state for i in records},
|
|
56
|
+
steps=steps,
|
|
57
|
+
lineage=lineage,
|
|
58
|
+
)
|
whiletrue/core/runner.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Literal, Protocol
|
|
6
|
+
|
|
7
|
+
from whiletrue.abort import AbortSignal
|
|
8
|
+
from whiletrue.core.caps import CapValidation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class RunnerCapabilities:
|
|
13
|
+
"""What a runner can do; `validate` rejects steps that ask for more."""
|
|
14
|
+
|
|
15
|
+
interactive: bool
|
|
16
|
+
structured_output: bool
|
|
17
|
+
resume: bool
|
|
18
|
+
fork: bool
|
|
19
|
+
budget: bool
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class AgentLimits:
|
|
24
|
+
#: Max spend in USD (hard where the runner supports it, an estimate otherwise).
|
|
25
|
+
budget_usd: float | None = None
|
|
26
|
+
turns: int | None = None
|
|
27
|
+
#: Wall-clock limit in milliseconds.
|
|
28
|
+
wall_ms: int | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class AgentRequest:
|
|
33
|
+
prompt: str
|
|
34
|
+
cwd: str
|
|
35
|
+
model: str | None = None
|
|
36
|
+
tools: list[str] | None = None
|
|
37
|
+
allowed_tools: list[str] | None = None
|
|
38
|
+
disallowed_tools: list[str] | None = None
|
|
39
|
+
permission_mode: str | None = None
|
|
40
|
+
system_prompt_append: str | None = None
|
|
41
|
+
#: JSON Schema the final structured output must satisfy.
|
|
42
|
+
output_schema: dict[str, Any] | None = None
|
|
43
|
+
limits: AgentLimits | None = None
|
|
44
|
+
#: New session id to use (fresh sessions).
|
|
45
|
+
session_id: str | None = None
|
|
46
|
+
#: Existing session to resume.
|
|
47
|
+
resume: str | None = None
|
|
48
|
+
#: With `resume`: branch into a new session instead of continuing the old one.
|
|
49
|
+
fork: bool = False
|
|
50
|
+
add_dirs: list[str] | None = None
|
|
51
|
+
append_system_prompt_file: str | None = None
|
|
52
|
+
mcp_config: list[str] | None = None
|
|
53
|
+
#: Reproducible mode: skip hooks, plugins, memory and auto-discovery (`claude --bare`).
|
|
54
|
+
bare: bool = False
|
|
55
|
+
#: Interactive runners: what happens to the pane when the step ends.
|
|
56
|
+
on_done: Literal["close", "keep"] = "close"
|
|
57
|
+
#: Identity of the step execution, for tying a live session back to it.
|
|
58
|
+
step_key: str | None = None
|
|
59
|
+
#: A short title for the pane, e.g. `#7 implement`.
|
|
60
|
+
label: str | None = None
|
|
61
|
+
#: The item's payload (what `whiletrue_context` returns to the agent).
|
|
62
|
+
item: dict[str, Any] | None = None
|
|
63
|
+
#: Name of the output cap and a validator for it (interactive runners check `whiletrue_submit`).
|
|
64
|
+
output_cap: str | None = None
|
|
65
|
+
validate_output: Callable[[Any], CapValidation] | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class AgentResult:
|
|
70
|
+
ok: bool
|
|
71
|
+
#: Final text answer.
|
|
72
|
+
text: str
|
|
73
|
+
#: Structured output when the request carried an output schema.
|
|
74
|
+
output: Any = None
|
|
75
|
+
session_id: str | None = None
|
|
76
|
+
cost_usd: float | None = None
|
|
77
|
+
turns: int | None = None
|
|
78
|
+
error: str | None = None
|
|
79
|
+
#: Why the step needs a person instead of failing: `needs_human`, `budget_exceeded`, ...
|
|
80
|
+
park: str | None = None
|
|
81
|
+
#: A person typed into the session, which lifts the step's limits.
|
|
82
|
+
steered: bool = False
|
|
83
|
+
#: The part of `cost_usd` spent after the steer.
|
|
84
|
+
steered_cost_usd: float | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class RunContext:
|
|
89
|
+
signal: AbortSignal
|
|
90
|
+
#: Raw runner events (tool calls, messages) for the event log.
|
|
91
|
+
on_event: Callable[[dict[str, Any]], None] | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Runner(Protocol):
|
|
95
|
+
id: str
|
|
96
|
+
capabilities: RunnerCapabilities
|
|
97
|
+
|
|
98
|
+
async def run(self, request: AgentRequest, ctx: RunContext) -> AgentResult: ...
|