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/__init__.py
ADDED
|
File without changes
|
whiletrue/__main__.py
ADDED
whiletrue/abort.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Cooperative cancellation, modelled on the web's AbortSignal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AbortSignal:
|
|
10
|
+
"""A one-way flag that can be awaited. Once aborted it stays aborted."""
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self._aborted = False
|
|
14
|
+
self._reason: str | None = None
|
|
15
|
+
self._event = asyncio.Event()
|
|
16
|
+
self._callbacks: list[Callable[[], None]] = []
|
|
17
|
+
self._timer: asyncio.TimerHandle | None = None
|
|
18
|
+
self._detach: list[Callable[[], None]] = []
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def aborted(self) -> bool:
|
|
22
|
+
return self._aborted
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def reason(self) -> str | None:
|
|
26
|
+
return self._reason
|
|
27
|
+
|
|
28
|
+
def abort(self, reason: str = "aborted") -> None:
|
|
29
|
+
if self._aborted:
|
|
30
|
+
return
|
|
31
|
+
self._aborted = True
|
|
32
|
+
self._reason = reason
|
|
33
|
+
self._event.set()
|
|
34
|
+
callbacks, self._callbacks = self._callbacks, []
|
|
35
|
+
for callback in callbacks:
|
|
36
|
+
callback()
|
|
37
|
+
|
|
38
|
+
async def wait(self) -> None:
|
|
39
|
+
"""Returns once the signal is aborted."""
|
|
40
|
+
await self._event.wait()
|
|
41
|
+
|
|
42
|
+
def on_abort(self, callback: Callable[[], None]) -> Callable[[], None]:
|
|
43
|
+
"""Calls `callback` on abort (immediately if already aborted). Returns an unsubscribe."""
|
|
44
|
+
if self._aborted:
|
|
45
|
+
callback()
|
|
46
|
+
return lambda: None
|
|
47
|
+
self._callbacks.append(callback)
|
|
48
|
+
|
|
49
|
+
def remove() -> None:
|
|
50
|
+
if callback in self._callbacks:
|
|
51
|
+
self._callbacks.remove(callback)
|
|
52
|
+
|
|
53
|
+
return remove
|
|
54
|
+
|
|
55
|
+
def dispose(self) -> None:
|
|
56
|
+
"""Releases timers and subscriptions held by a derived signal (timeout / any)."""
|
|
57
|
+
if self._timer is not None:
|
|
58
|
+
self._timer.cancel()
|
|
59
|
+
self._timer = None
|
|
60
|
+
for detach in self._detach:
|
|
61
|
+
detach()
|
|
62
|
+
self._detach.clear()
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def any(*signals: AbortSignal) -> AbortSignal:
|
|
66
|
+
"""A signal that aborts as soon as any of `signals` does."""
|
|
67
|
+
derived = AbortSignal()
|
|
68
|
+
for source in signals:
|
|
69
|
+
derived._detach.append(source.on_abort(derived.abort))
|
|
70
|
+
return derived
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def timeout(ms: float) -> AbortSignal:
|
|
74
|
+
"""A signal that aborts after `ms` milliseconds."""
|
|
75
|
+
derived = AbortSignal()
|
|
76
|
+
derived._timer = asyncio.get_running_loop().call_later(
|
|
77
|
+
ms / 1000, lambda: derived.abort("timeout")
|
|
78
|
+
)
|
|
79
|
+
return derived
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Built-in modules: shell, gate.*, exec, set, agent, sessions, human.approve, comments."""
|
|
2
|
+
|
|
3
|
+
from whiletrue.basics.agent import AgentConfig, agent_module
|
|
4
|
+
from whiletrue.basics.comments import (
|
|
5
|
+
CommentReplies,
|
|
6
|
+
CommentReply,
|
|
7
|
+
comments_reply_module,
|
|
8
|
+
gate_comments_answered_module,
|
|
9
|
+
)
|
|
10
|
+
from whiletrue.basics.diff_size import DiffSizeConfig, diff_size_module
|
|
11
|
+
from whiletrue.basics.exec_ import ExecConfig, exec_module
|
|
12
|
+
from whiletrue.basics.history import (
|
|
13
|
+
DistillConfig,
|
|
14
|
+
HistoryConfig,
|
|
15
|
+
context_history_module,
|
|
16
|
+
history_distill_module,
|
|
17
|
+
render_history,
|
|
18
|
+
)
|
|
19
|
+
from whiletrue.basics.human import ApproveConfig, approve_strategy
|
|
20
|
+
from whiletrue.basics.json_source import JsonSource
|
|
21
|
+
from whiletrue.basics.markdown_source import MarkdownTasksSource
|
|
22
|
+
from whiletrue.basics.process import CommandResult, run_command, tail
|
|
23
|
+
from whiletrue.basics.sessions import (
|
|
24
|
+
SessionEndConfig,
|
|
25
|
+
context_reset_module,
|
|
26
|
+
session_end_module,
|
|
27
|
+
)
|
|
28
|
+
from whiletrue.basics.set_ import SetConfig, set_module
|
|
29
|
+
from whiletrue.basics.shell import (
|
|
30
|
+
GateTestsConfig,
|
|
31
|
+
ShellConfig,
|
|
32
|
+
gate_shell_module,
|
|
33
|
+
gate_tests_module,
|
|
34
|
+
shell_module,
|
|
35
|
+
)
|
|
36
|
+
from whiletrue.basics.wake import FileExistsWake, TimerWake
|
|
37
|
+
from whiletrue.core import PluginApi, SimplePlugin
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _register(api: PluginApi) -> None:
|
|
41
|
+
api.register_action(shell_module)
|
|
42
|
+
api.register_action(gate_shell_module)
|
|
43
|
+
api.register_action(gate_tests_module)
|
|
44
|
+
api.register_action(diff_size_module)
|
|
45
|
+
api.register_action(exec_module)
|
|
46
|
+
api.register_action(set_module)
|
|
47
|
+
api.register_action(agent_module)
|
|
48
|
+
api.register_action(session_end_module)
|
|
49
|
+
api.register_action(context_reset_module)
|
|
50
|
+
api.register_action(comments_reply_module)
|
|
51
|
+
api.register_action(context_history_module)
|
|
52
|
+
api.register_action(history_distill_module)
|
|
53
|
+
api.register_action(gate_comments_answered_module)
|
|
54
|
+
api.register_strategy(approve_strategy)
|
|
55
|
+
api.register_provider("source", "json", JsonSource())
|
|
56
|
+
api.register_provider("source", "markdown_tasks", MarkdownTasksSource())
|
|
57
|
+
api.register_provider("wake", "file_exists", FileExistsWake())
|
|
58
|
+
api.register_provider("wake", "timer", TimerWake())
|
|
59
|
+
api.register_cap("CommentReplies", CommentReplies)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
#: Registers the built-in modules through the same public plugin API third parties use.
|
|
63
|
+
basics_plugin = SimplePlugin(name="whiletrue.basics", register=_register)
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"AgentConfig",
|
|
67
|
+
"ApproveConfig",
|
|
68
|
+
"CommandResult",
|
|
69
|
+
"CommentReplies",
|
|
70
|
+
"CommentReply",
|
|
71
|
+
"DiffSizeConfig",
|
|
72
|
+
"DistillConfig",
|
|
73
|
+
"ExecConfig",
|
|
74
|
+
"GateTestsConfig",
|
|
75
|
+
"HistoryConfig",
|
|
76
|
+
"JsonSource",
|
|
77
|
+
"MarkdownTasksSource",
|
|
78
|
+
"SessionEndConfig",
|
|
79
|
+
"SetConfig",
|
|
80
|
+
"ShellConfig",
|
|
81
|
+
"agent_module",
|
|
82
|
+
"approve_strategy",
|
|
83
|
+
"basics_plugin",
|
|
84
|
+
"comments_reply_module",
|
|
85
|
+
"context_history_module",
|
|
86
|
+
"context_reset_module",
|
|
87
|
+
"diff_size_module",
|
|
88
|
+
"exec_module",
|
|
89
|
+
"gate_comments_answered_module",
|
|
90
|
+
"gate_shell_module",
|
|
91
|
+
"gate_tests_module",
|
|
92
|
+
"history_distill_module",
|
|
93
|
+
"render_history",
|
|
94
|
+
"run_command",
|
|
95
|
+
"session_end_module",
|
|
96
|
+
"set_module",
|
|
97
|
+
"shell_module",
|
|
98
|
+
"tail",
|
|
99
|
+
]
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from whiletrue.abort import AbortSignal
|
|
10
|
+
from whiletrue.core import (
|
|
11
|
+
ActionModule,
|
|
12
|
+
AgentDefinition,
|
|
13
|
+
AgentLimits,
|
|
14
|
+
AgentRequest,
|
|
15
|
+
ConfigError,
|
|
16
|
+
ConfigIssue,
|
|
17
|
+
Emit,
|
|
18
|
+
Ports,
|
|
19
|
+
PortSpec,
|
|
20
|
+
Registry,
|
|
21
|
+
RunContext,
|
|
22
|
+
StepContext,
|
|
23
|
+
ValidateContext,
|
|
24
|
+
define_action,
|
|
25
|
+
load_agent_definition,
|
|
26
|
+
render_template,
|
|
27
|
+
)
|
|
28
|
+
from whiletrue.core.module import PortValues
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AgentConfig(BaseModel):
|
|
32
|
+
uses: str = Field(min_length=1)
|
|
33
|
+
runner: str | None = None
|
|
34
|
+
vars: dict[str, Any] = Field(default_factory=lambda: {})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
#: Caps that carry no useful JSON Schema constraint for structured output.
|
|
38
|
+
_UNSTRUCTURED = {"Text", "Any"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _runner_issues(
|
|
42
|
+
definition: AgentDefinition, runner_id: str, registry: Registry
|
|
43
|
+
) -> list[ConfigIssue]:
|
|
44
|
+
runner = registry.get_runner(runner_id)
|
|
45
|
+
if runner is None:
|
|
46
|
+
return [
|
|
47
|
+
ConfigIssue(
|
|
48
|
+
"error",
|
|
49
|
+
f'Unknown runner "{runner_id}" (e.g. claude-code-headless, opencode)',
|
|
50
|
+
)
|
|
51
|
+
]
|
|
52
|
+
issues: list[ConfigIssue] = []
|
|
53
|
+
out_cap = next(iter(definition.outputs.values()), None)
|
|
54
|
+
if out_cap and out_cap not in _UNSTRUCTURED and not runner.capabilities.structured_output:
|
|
55
|
+
issues.append(
|
|
56
|
+
ConfigIssue(
|
|
57
|
+
"error",
|
|
58
|
+
f'Agent "{definition.name}" declares structured output ({out_cap}) '
|
|
59
|
+
f'but runner "{runner_id}" cannot produce it',
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
if (
|
|
63
|
+
definition.limits is not None
|
|
64
|
+
and definition.limits.budget_usd is not None
|
|
65
|
+
and not runner.capabilities.budget
|
|
66
|
+
):
|
|
67
|
+
issues.append(
|
|
68
|
+
ConfigIssue(
|
|
69
|
+
"error",
|
|
70
|
+
f'Agent "{definition.name}" sets a budget but runner "{runner_id}" '
|
|
71
|
+
"cannot enforce one",
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
if definition.fork and not runner.capabilities.fork:
|
|
75
|
+
issues.append(
|
|
76
|
+
ConfigIssue(
|
|
77
|
+
"error",
|
|
78
|
+
f'Agent "{definition.name}" asks to fork a session but runner "{runner_id}" '
|
|
79
|
+
"cannot fork",
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
if definition.bare and runner.capabilities.interactive:
|
|
83
|
+
issues.append(
|
|
84
|
+
ConfigIssue(
|
|
85
|
+
"warning",
|
|
86
|
+
f'Agent "{definition.name}" sets `bare`, which turns off hooks: runner '
|
|
87
|
+
f'"{runner_id}" then cannot nudge, enforce limits or detect steering',
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
if definition.session != "fresh" and not runner.capabilities.resume:
|
|
91
|
+
issues.append(
|
|
92
|
+
ConfigIssue(
|
|
93
|
+
"error",
|
|
94
|
+
f'Agent "{definition.name}" asks for session "{definition.session}" '
|
|
95
|
+
f'but runner "{runner_id}" cannot resume sessions',
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return issues
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _validate(config: AgentConfig, vctx: ValidateContext) -> list[ConfigIssue]:
|
|
102
|
+
try:
|
|
103
|
+
definition = load_agent_definition(Path(vctx.cwd) / config.uses)
|
|
104
|
+
except ConfigError as error:
|
|
105
|
+
return [ConfigIssue("error", str(error))]
|
|
106
|
+
except OSError as error:
|
|
107
|
+
return [ConfigIssue("error", f"Cannot read agent file: {error}")]
|
|
108
|
+
issues: list[ConfigIssue] = []
|
|
109
|
+
if definition.limits is None:
|
|
110
|
+
issues.append(
|
|
111
|
+
ConfigIssue(
|
|
112
|
+
"error",
|
|
113
|
+
f'Agent "{definition.name}" needs `limits` (budget_usd, turns, wall): '
|
|
114
|
+
"agent steps are bounded by default",
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
return [*issues, *_runner_issues(definition, config.runner or definition.runner, vctx.registry)]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def _run(input: PortValues, ctx: StepContext[AgentConfig]) -> Emit:
|
|
121
|
+
definition = load_agent_definition(Path(ctx.cwd) / ctx.config.uses)
|
|
122
|
+
runner_id = ctx.config.runner or definition.runner
|
|
123
|
+
runner = ctx.registry.get_runner(runner_id)
|
|
124
|
+
if runner is None:
|
|
125
|
+
return Emit("fail", {"error": f'Unknown runner "{runner_id}"'})
|
|
126
|
+
|
|
127
|
+
prompt = render_template(
|
|
128
|
+
definition.prompt,
|
|
129
|
+
{**ctx.scope.snapshot(), "input": input.get("in"), **ctx.config.vars},
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
out_cap = next(iter(definition.outputs.values()), None)
|
|
133
|
+
structured = out_cap is not None and out_cap not in _UNSTRUCTURED
|
|
134
|
+
wall: AbortSignal | None = None
|
|
135
|
+
signal = ctx.signal
|
|
136
|
+
if definition.limits is not None and definition.limits.wall_ms is not None:
|
|
137
|
+
wall = AbortSignal.timeout(definition.limits.wall_ms)
|
|
138
|
+
signal = AbortSignal.any(ctx.signal, wall)
|
|
139
|
+
|
|
140
|
+
sessions: dict[str, str] | None = ctx.scope.get("sessions")
|
|
141
|
+
shared = definition.session if definition.session != "fresh" and sessions is not None else None
|
|
142
|
+
existing = sessions.get(shared) if sessions is not None and shared else None
|
|
143
|
+
new_session = str(uuid.uuid4())
|
|
144
|
+
|
|
145
|
+
def path(p: str) -> str:
|
|
146
|
+
return str(Path(ctx.cwd) / p)
|
|
147
|
+
|
|
148
|
+
request = AgentRequest(
|
|
149
|
+
prompt=prompt,
|
|
150
|
+
cwd=ctx.cwd,
|
|
151
|
+
model=definition.model,
|
|
152
|
+
allowed_tools=definition.tools,
|
|
153
|
+
permission_mode=definition.permission_mode,
|
|
154
|
+
output_schema=ctx.registry.caps.to_json_schema(out_cap) if structured and out_cap else None,
|
|
155
|
+
limits=AgentLimits(
|
|
156
|
+
budget_usd=definition.limits.budget_usd,
|
|
157
|
+
turns=definition.limits.turns,
|
|
158
|
+
wall_ms=definition.limits.wall_ms,
|
|
159
|
+
)
|
|
160
|
+
if definition.limits is not None
|
|
161
|
+
else None,
|
|
162
|
+
session_id=None if existing else new_session,
|
|
163
|
+
resume=existing,
|
|
164
|
+
fork=bool(existing) and definition.fork,
|
|
165
|
+
add_dirs=[path(d) for d in definition.add_dirs] if definition.add_dirs else None,
|
|
166
|
+
append_system_prompt_file=path(definition.append_system_prompt_file)
|
|
167
|
+
if definition.append_system_prompt_file
|
|
168
|
+
else None,
|
|
169
|
+
mcp_config=[path(c) for c in definition.mcp_config] if definition.mcp_config else None,
|
|
170
|
+
bare=definition.bare,
|
|
171
|
+
on_done=definition.on_done,
|
|
172
|
+
step_key=ctx.step_key,
|
|
173
|
+
label=f"{ctx.item_id} {definition.name}",
|
|
174
|
+
item=ctx.scope.get("item"),
|
|
175
|
+
output_cap=out_cap,
|
|
176
|
+
validate_output=(lambda v, cap=out_cap: ctx.registry.caps.validate(cap, v))
|
|
177
|
+
if structured and out_cap
|
|
178
|
+
else None,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def on_event(event: dict[str, Any]) -> None:
|
|
182
|
+
if event.get("kind") == "steer" and ctx.store is not None:
|
|
183
|
+
ctx.store.add_steer(
|
|
184
|
+
run_id=ctx.run_id,
|
|
185
|
+
item_id=ctx.item_id,
|
|
186
|
+
step=ctx.node_id,
|
|
187
|
+
prompt=str(event.get("prompt", "")),
|
|
188
|
+
created_at=ctx.now(),
|
|
189
|
+
)
|
|
190
|
+
ctx.bus.emit(
|
|
191
|
+
"agent.event",
|
|
192
|
+
ctx.run_id,
|
|
193
|
+
{"node": ctx.node_id, **event},
|
|
194
|
+
item_id=ctx.item_id,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
on_event({"kind": "runner", "runner": runner_id})
|
|
198
|
+
try:
|
|
199
|
+
if ctx.agent_slots is not None:
|
|
200
|
+
async with ctx.agent_slots:
|
|
201
|
+
result = await runner.run(request, RunContext(signal=signal, on_event=on_event))
|
|
202
|
+
else:
|
|
203
|
+
result = await runner.run(request, RunContext(signal=signal, on_event=on_event))
|
|
204
|
+
finally:
|
|
205
|
+
if wall is not None:
|
|
206
|
+
wall.dispose()
|
|
207
|
+
signal.dispose()
|
|
208
|
+
if shared and sessions is not None and not (existing and definition.fork):
|
|
209
|
+
# Remember the conversation (even a failed one) so a retry or a later step continues it.
|
|
210
|
+
sessions[shared] = result.session_id or existing or new_session
|
|
211
|
+
if result.cost_usd:
|
|
212
|
+
ctx.record_cost(result.cost_usd)
|
|
213
|
+
if result.steered_cost_usd is not None:
|
|
214
|
+
on_event(
|
|
215
|
+
{
|
|
216
|
+
"kind": "cost",
|
|
217
|
+
"agent_usd": (result.cost_usd or 0.0) - result.steered_cost_usd,
|
|
218
|
+
"steered_usd": result.steered_cost_usd,
|
|
219
|
+
}
|
|
220
|
+
)
|
|
221
|
+
if result.park:
|
|
222
|
+
return Emit(
|
|
223
|
+
"needs_human",
|
|
224
|
+
{"reason": result.park, "detail": result.error, "text": result.text},
|
|
225
|
+
)
|
|
226
|
+
if not result.ok:
|
|
227
|
+
return Emit("fail", {"error": result.error or "agent failed", "text": result.text})
|
|
228
|
+
value = result.output if result.output is not None else result.text
|
|
229
|
+
if out_cap:
|
|
230
|
+
check = ctx.registry.caps.validate(out_cap, value)
|
|
231
|
+
if not check.ok:
|
|
232
|
+
return Emit(
|
|
233
|
+
"fail",
|
|
234
|
+
{"error": f"Agent output does not match {out_cap}: {'; '.join(check.errors)}"},
|
|
235
|
+
)
|
|
236
|
+
return Emit("ok", value)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
#: Runs a prompt-defined agent (markdown with frontmatter) on a runner.
|
|
240
|
+
agent_module: ActionModule[AgentConfig] = define_action(
|
|
241
|
+
ActionModule(
|
|
242
|
+
id="agent",
|
|
243
|
+
config=AgentConfig,
|
|
244
|
+
ports=Ports(inputs=[PortSpec("in", "Any")], outputs=[PortSpec("out", "Any")]),
|
|
245
|
+
effects="agentic",
|
|
246
|
+
validate=_validate,
|
|
247
|
+
run=_run,
|
|
248
|
+
)
|
|
249
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from whiletrue.core import (
|
|
6
|
+
ActionModule,
|
|
7
|
+
CommentsContext,
|
|
8
|
+
Emit,
|
|
9
|
+
EmptyConfig,
|
|
10
|
+
Ports,
|
|
11
|
+
PortSpec,
|
|
12
|
+
StepContext,
|
|
13
|
+
define_action,
|
|
14
|
+
with_prefix,
|
|
15
|
+
)
|
|
16
|
+
from whiletrue.core.module import PortValues
|
|
17
|
+
|
|
18
|
+
COMMENT_KINDS = ("question", "change_request", "nit", "approval", "out_of_scope")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CommentReply(BaseModel):
|
|
22
|
+
comment_id: int
|
|
23
|
+
#: How the comment was classified.
|
|
24
|
+
kind: str = "question"
|
|
25
|
+
#: What was said back: the answer, "fixed in abc123", or "declined because ...".
|
|
26
|
+
reply: str = Field(min_length=1)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CommentReplies(BaseModel):
|
|
30
|
+
"""What a responder agent returns: one reply per comment it was given."""
|
|
31
|
+
|
|
32
|
+
replies: list[CommentReply]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def _reply(input: PortValues, ctx: StepContext[EmptyConfig]) -> Emit:
|
|
36
|
+
store = ctx.store
|
|
37
|
+
if store is None:
|
|
38
|
+
return Emit("fail", {"error": "comments.reply needs a state store"})
|
|
39
|
+
payload = CommentReplies.model_validate(input.get("in") or {})
|
|
40
|
+
known = {c.id: c for c in store.list_comments(ctx.item_id)}
|
|
41
|
+
item = ctx.scope.get("item") or {"id": ctx.item_id}
|
|
42
|
+
posted: list[int] = []
|
|
43
|
+
for reply in payload.replies:
|
|
44
|
+
comment = known.get(reply.comment_id)
|
|
45
|
+
if comment is None:
|
|
46
|
+
return Emit("fail", {"error": f"No comment {reply.comment_id} on {ctx.item_id}"})
|
|
47
|
+
if comment.answered:
|
|
48
|
+
continue
|
|
49
|
+
key = f"reply:{ctx.item_id}:{comment.source}:{comment.external_id}"
|
|
50
|
+
effect = store.effect_get(key)
|
|
51
|
+
body = with_prefix(reply.reply)
|
|
52
|
+
if effect is None:
|
|
53
|
+
provider = ctx.registry.get_provider("comments", comment.source)
|
|
54
|
+
store.effect_put(
|
|
55
|
+
key, run_id=ctx.run_id, status="started", ref=None, created_at=ctx.now()
|
|
56
|
+
)
|
|
57
|
+
if provider is not None:
|
|
58
|
+
ref = await provider.reply(
|
|
59
|
+
comment, body, CommentsContext(ctx.cwd, ctx.now, ctx.item_id, item)
|
|
60
|
+
)
|
|
61
|
+
else:
|
|
62
|
+
ref = None # a local comment: the answer is only recorded
|
|
63
|
+
store.effect_put(key, run_id=ctx.run_id, status="done", ref=ref, created_at=ctx.now())
|
|
64
|
+
# A "started" effect with no "done" means a crash mid-post: do not risk a duplicate.
|
|
65
|
+
store.answer_comment(comment.id, body)
|
|
66
|
+
ctx.bus.emit(
|
|
67
|
+
"comment.answered",
|
|
68
|
+
ctx.run_id,
|
|
69
|
+
{"comment": comment.id, "source": comment.source, "kind": reply.kind},
|
|
70
|
+
item_id=ctx.item_id,
|
|
71
|
+
)
|
|
72
|
+
posted.append(comment.id)
|
|
73
|
+
return Emit("ok", {"answered": posted})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
#: Posts the agent's replies in each comment's own thread (prefixed `[whiletrue]`, once only).
|
|
77
|
+
comments_reply_module: ActionModule[EmptyConfig] = define_action(
|
|
78
|
+
ActionModule(
|
|
79
|
+
id="comments.reply",
|
|
80
|
+
config=EmptyConfig,
|
|
81
|
+
ports=Ports(inputs=[PortSpec("in", "CommentReplies")], outputs=[PortSpec("out", "Any")]),
|
|
82
|
+
effects="side-effect",
|
|
83
|
+
cacheable=False,
|
|
84
|
+
idempotency_key=lambda _i, ctx: f"comments.reply:{ctx.item_id}:{ctx.node_id}",
|
|
85
|
+
run=_reply,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def _answered(_input: PortValues, ctx: StepContext[EmptyConfig]) -> Emit:
|
|
91
|
+
if ctx.store is None:
|
|
92
|
+
return Emit("fail", {"error": "gate.comments_answered needs a state store"})
|
|
93
|
+
open_comments = ctx.store.list_comments(ctx.item_id, unanswered_only=True)
|
|
94
|
+
if open_comments:
|
|
95
|
+
ids = ", ".join(f"#{c.id} ({c.author})" for c in open_comments)
|
|
96
|
+
return Emit(
|
|
97
|
+
"fail",
|
|
98
|
+
{"error": f"{len(open_comments)} comment(s) still have no reply: {ids}"},
|
|
99
|
+
)
|
|
100
|
+
return Emit("ok", _input.get("in"))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
#: Fails while any comment on the item (human or reviewer) has no reply.
|
|
104
|
+
gate_comments_answered_module: ActionModule[EmptyConfig] = define_action(
|
|
105
|
+
ActionModule(
|
|
106
|
+
id="gate.comments_answered",
|
|
107
|
+
config=EmptyConfig,
|
|
108
|
+
ports=Ports(inputs=[PortSpec("in", "Any")], outputs=[PortSpec("out", "Any")]),
|
|
109
|
+
effects="deterministic",
|
|
110
|
+
cacheable=False,
|
|
111
|
+
run=_answered,
|
|
112
|
+
)
|
|
113
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, PositiveInt
|
|
4
|
+
|
|
5
|
+
from whiletrue.basics.process import run_command, tail
|
|
6
|
+
from whiletrue.core import (
|
|
7
|
+
ActionModule,
|
|
8
|
+
ConfigIssue,
|
|
9
|
+
Emit,
|
|
10
|
+
Ports,
|
|
11
|
+
PortSpec,
|
|
12
|
+
StepContext,
|
|
13
|
+
define_action,
|
|
14
|
+
)
|
|
15
|
+
from whiletrue.core.module import PortValues
|
|
16
|
+
|
|
17
|
+
#: git's well-known empty tree, so `diff` works in a repository without commits.
|
|
18
|
+
_EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DiffSizeConfig(BaseModel):
|
|
22
|
+
max_lines: PositiveInt | None = None
|
|
23
|
+
max_files: PositiveInt | None = None
|
|
24
|
+
#: Revision to diff the working tree against. Default: HEAD.
|
|
25
|
+
base: str = "HEAD"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _validate(config: DiffSizeConfig, _vctx: object) -> list[ConfigIssue]:
|
|
29
|
+
if config.max_lines is None and config.max_files is None:
|
|
30
|
+
return [ConfigIssue("error", "`gate.diff_size` needs `max_lines` and/or `max_files`")]
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _git(ctx: StepContext[DiffSizeConfig], *args: str):
|
|
35
|
+
return await run_command("git", list(args), cwd=ctx.cwd, signal=ctx.signal)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _run(_input: PortValues, ctx: StepContext[DiffSizeConfig]) -> Emit:
|
|
39
|
+
cfg = ctx.config
|
|
40
|
+
base = cfg.base
|
|
41
|
+
if base == "HEAD" and (await _git(ctx, "rev-parse", "--verify", "HEAD")).code != 0:
|
|
42
|
+
base = _EMPTY_TREE
|
|
43
|
+
diff = await _git(ctx, "diff", "--numstat", base)
|
|
44
|
+
if diff.code != 0:
|
|
45
|
+
return Emit("fail", {"error": "git diff failed", "output": tail(diff.stderr)})
|
|
46
|
+
untracked = await _git(ctx, "ls-files", "--others", "--exclude-standard")
|
|
47
|
+
if untracked.code != 0:
|
|
48
|
+
return Emit("fail", {"error": "git ls-files failed", "output": tail(untracked.stderr)})
|
|
49
|
+
lines = 0
|
|
50
|
+
files = 0
|
|
51
|
+
for row in diff.stdout.splitlines():
|
|
52
|
+
added, deleted, _name = [*row.split("\t", 2), "", "", ""][:3]
|
|
53
|
+
files += 1
|
|
54
|
+
# Binary files show "-" for both counts.
|
|
55
|
+
lines += (int(added) if added.isdigit() else 0) + (int(deleted) if deleted.isdigit() else 0)
|
|
56
|
+
files += len([n for n in untracked.stdout.splitlines() if n])
|
|
57
|
+
stats = {"lines": lines, "files": files}
|
|
58
|
+
problems: list[str] = []
|
|
59
|
+
if cfg.max_lines is not None and lines > cfg.max_lines:
|
|
60
|
+
problems.append(f"{lines} changed lines exceed the limit of {cfg.max_lines}")
|
|
61
|
+
if cfg.max_files is not None and files > cfg.max_files:
|
|
62
|
+
problems.append(f"{files} changed files exceed the limit of {cfg.max_files}")
|
|
63
|
+
if problems:
|
|
64
|
+
return Emit("fail", {"error": "; ".join(problems), **stats})
|
|
65
|
+
return Emit("ok", stats)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
#: A deterministic gate: fails when the working tree differs from `base` by more than the limits
|
|
69
|
+
#: (changed lines and files, untracked files counted as files).
|
|
70
|
+
diff_size_module: ActionModule[DiffSizeConfig] = define_action(
|
|
71
|
+
ActionModule(
|
|
72
|
+
id="gate.diff_size",
|
|
73
|
+
config=DiffSizeConfig,
|
|
74
|
+
ports=Ports(inputs=[PortSpec("in", "Any")], outputs=[PortSpec("out", "Any")]),
|
|
75
|
+
effects="deterministic",
|
|
76
|
+
cacheable=False,
|
|
77
|
+
validate=_validate, # pyright: ignore[reportArgumentType]
|
|
78
|
+
run=_run,
|
|
79
|
+
)
|
|
80
|
+
)
|