autoforge-cli 0.1.0a0__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.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/premerge.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Pre-merge evidence the controller produces itself (issue #42).
|
|
2
|
+
|
|
3
|
+
A green required check proves that the workflow defining it ran to
|
|
4
|
+
completion on the PR. It does not prove *which* definition ran (GitHub runs
|
|
5
|
+
the PR's own workflow files and reports the result under the same name) and
|
|
6
|
+
it does not prove what the tests in the PR *assert* (the commands execute
|
|
7
|
+
the PR's own code). This module holds the two controller-side answers the
|
|
8
|
+
merge gate adds on top of the hosted check:
|
|
9
|
+
|
|
10
|
+
- :func:`describe_definition_difference` compares the job/step structure
|
|
11
|
+
of the run that produced the PR's check with the base branch's own run of
|
|
12
|
+
the same workflow, so a redefined workflow is a named difference rather
|
|
13
|
+
than a green check.
|
|
14
|
+
- :func:`export_commit_tree` materialises the exact reviewed commit into a
|
|
15
|
+
private temporary directory -- no worktree, no branch, no ``.git`` -- so
|
|
16
|
+
``merge.verification_commands`` can run the reviewed code on the
|
|
17
|
+
operator's machine without touching the operator's checkout. The bytes
|
|
18
|
+
are the committed bytes, proven by hashing them back to the commit's tree
|
|
19
|
+
(:mod:`autoforge.materialize`, #171).
|
|
20
|
+
|
|
21
|
+
Both are pure with respect to workflow state; the engine decides what a
|
|
22
|
+
difference or a failing command means for the phase.
|
|
23
|
+
|
|
24
|
+
The git processes here run in the operator's repository, whose refs,
|
|
25
|
+
configuration and hooks an agent could have written (ADR 0004 §2.11). Each
|
|
26
|
+
one therefore reads a commit as itself (ADR 0004 D7.5): replacement objects
|
|
27
|
+
and the commit-graph are off, so a ``refs/replace/*`` entry or a forged
|
|
28
|
+
``objects/info/commit-graph`` can neither make a missing commit present nor
|
|
29
|
+
swap the tree the export writes. Hooks and the file-system monitor are off
|
|
30
|
+
too (D7.3), and each process starts from an allow-listed environment that
|
|
31
|
+
holds no credential and no operator ``GIT_*`` variable. The export reads
|
|
32
|
+
objects only (``ls-tree`` and ``cat-file``), so no attribute, filter driver
|
|
33
|
+
or eol conversion the operator or the candidate configured takes part.
|
|
34
|
+
Fetching the reviewed commit is not done here: it is the controller's own
|
|
35
|
+
network operation, :meth:`autoforge.git_transport.GitTransport.fetch`, which
|
|
36
|
+
reads no repository configuration at all (D7.1).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from collections import Counter
|
|
42
|
+
from collections.abc import Callable, Iterator
|
|
43
|
+
from contextlib import contextmanager
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
from .errors import GitTransportError, VerificationError
|
|
48
|
+
from .executor import ExecutionRequest, ExecutionResult
|
|
49
|
+
from .git_transport import GitTransport, local_git_request
|
|
50
|
+
from .github import WorkflowRunJobs
|
|
51
|
+
from .materialize import materialize, read_object_format
|
|
52
|
+
|
|
53
|
+
Runner = Callable[[ExecutionRequest], ExecutionResult]
|
|
54
|
+
|
|
55
|
+
# The export is plumbing, never a checkout: objects are read by id and
|
|
56
|
+
# written out by the controller. The operator's index, HEAD, working tree
|
|
57
|
+
# and stash are not read or written.
|
|
58
|
+
_GIT_TIMEOUT_SECONDS = 600
|
|
59
|
+
|
|
60
|
+
# Where the merge gate's export is created, under the system temporary
|
|
61
|
+
# directory (ADR 0006 D6.2).
|
|
62
|
+
EXPORT_PREFIX = "autoforge-premerge-"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _local_request(repo: str, args: list[str]) -> ExecutionRequest:
|
|
66
|
+
"""One hardened git process in the operator's repository ``repo``."""
|
|
67
|
+
return local_git_request(["-C", repo, *args], timeout_seconds=_GIT_TIMEOUT_SECONDS)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def describe_definition_difference(pr_jobs: WorkflowRunJobs, base_jobs: WorkflowRunJobs) -> str:
|
|
71
|
+
"""Name the first way the PR run's structure differs from the base run's.
|
|
72
|
+
|
|
73
|
+
Returns "" only when both runs have the same jobs and, per job, the same
|
|
74
|
+
step names in the same order. Job order is irrelevant (matrix legs start
|
|
75
|
+
in any order); step order is part of the definition and is compared.
|
|
76
|
+
|
|
77
|
+
Jobs are compared as a multiset: two jobs of one run may share a name
|
|
78
|
+
(``name:`` set without interpolating the matrix, two job ids with the
|
|
79
|
+
same display name), and a job added under a name the run already has
|
|
80
|
+
must be an extra job, not a duplicate that collapses into the one kept.
|
|
81
|
+
"""
|
|
82
|
+
pr_shape = pr_jobs.structure()
|
|
83
|
+
base_shape = base_jobs.structure()
|
|
84
|
+
if pr_shape == base_shape:
|
|
85
|
+
return ""
|
|
86
|
+
pr_names = Counter(name for name, _ in pr_shape)
|
|
87
|
+
base_names = Counter(name for name, _ in base_shape)
|
|
88
|
+
for name in sorted(base_names):
|
|
89
|
+
if pr_names[name] < base_names[name]:
|
|
90
|
+
if base_names[name] == 1:
|
|
91
|
+
return f"job {name!r} of the base branch's run is missing from the PR's run"
|
|
92
|
+
return (
|
|
93
|
+
f"the base branch's run has {base_names[name]} jobs named {name!r}, the PR's "
|
|
94
|
+
f"run has {pr_names[name]}"
|
|
95
|
+
)
|
|
96
|
+
for name in sorted(pr_names):
|
|
97
|
+
if pr_names[name] > base_names[name]:
|
|
98
|
+
if base_names[name] == 0:
|
|
99
|
+
return f"job {name!r} of the PR's run does not exist in the base branch's run"
|
|
100
|
+
return (
|
|
101
|
+
f"the PR's run has {pr_names[name]} jobs named {name!r}, the base branch's "
|
|
102
|
+
f"run has {base_names[name]}"
|
|
103
|
+
)
|
|
104
|
+
# Same job names with the same multiplicities; the sorted shapes pair
|
|
105
|
+
# same-named jobs up by their step lists, so the first pair that differs
|
|
106
|
+
# is a real difference even when the name is shared.
|
|
107
|
+
for (name, base_steps), (_, pr_steps) in zip(base_shape, pr_shape, strict=True):
|
|
108
|
+
if base_steps == pr_steps:
|
|
109
|
+
continue
|
|
110
|
+
for index, (base_step, pr_step) in enumerate(zip(base_steps, pr_steps, strict=False)):
|
|
111
|
+
if base_step != pr_step:
|
|
112
|
+
return (
|
|
113
|
+
f"job {name!r} step {index + 1} is {pr_step!r} in the PR's run but "
|
|
114
|
+
f"{base_step!r} in the base branch's run"
|
|
115
|
+
)
|
|
116
|
+
if len(pr_steps) < len(base_steps):
|
|
117
|
+
return f"job {name!r} lacks step {base_steps[len(pr_steps)]!r} of the base branch's run"
|
|
118
|
+
return f"job {name!r} has an extra step {pr_steps[len(base_steps)]!r} in the PR's run"
|
|
119
|
+
# Unreachable when the shapes differ, but a difference must never be
|
|
120
|
+
# reported as "" -- the gate fails closed on what it could not name.
|
|
121
|
+
return "the PR's run and the base branch's run differ in structure"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class ExportedTree:
|
|
126
|
+
"""A commit's tree written out under ``root``; ``root`` is deleted afterwards."""
|
|
127
|
+
|
|
128
|
+
sha: str
|
|
129
|
+
root: Path
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def commit_is_local(runner: Runner, repo: str, sha: str) -> bool:
|
|
133
|
+
"""Whether ``repo``'s object store holds a commit named ``sha``.
|
|
134
|
+
|
|
135
|
+
A missing commit is missing even when a replacement ref or the
|
|
136
|
+
commit-graph names it: neither is consulted. Existence only; the bytes
|
|
137
|
+
are not authenticated here.
|
|
138
|
+
"""
|
|
139
|
+
res = runner(_local_request(repo, ["cat-file", "-e", f"{sha}^{{commit}}"]))
|
|
140
|
+
return not res.timed_out and res.exit_code == 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def fetch_pr_head(transport: GitTransport, pr_number: int) -> None:
|
|
144
|
+
"""Fetch the objects of ``refs/pull/<n>/head`` so the reviewed commit is local.
|
|
145
|
+
|
|
146
|
+
The controller's own fetch (ADR 0004 D6.4, D7.1): an explicit URL, no
|
|
147
|
+
repository configuration, objects only. No ref is created or moved, so
|
|
148
|
+
the operator's branches are untouched. A failure is a
|
|
149
|
+
:class:`VerificationError`, which the caller reads as inconclusive.
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
transport.fetch([f"refs/pull/{pr_number}/head"])
|
|
153
|
+
except GitTransportError as exc:
|
|
154
|
+
raise VerificationError(f"fetching refs/pull/{pr_number}/head failed: {exc}") from exc
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@contextmanager
|
|
158
|
+
def export_commit_tree(runner: Runner, repo: str, sha: str) -> Iterator[ExportedTree]:
|
|
159
|
+
"""Write the tree of ``sha`` into a fresh temporary directory, then delete it.
|
|
160
|
+
|
|
161
|
+
The byte-exact materializer (:func:`autoforge.materialize.materialize`)
|
|
162
|
+
writes every blob of exactly that commit as its committed bytes and
|
|
163
|
+
proves the directory hashes back to the commit's tree before the body
|
|
164
|
+
runs: no ``.git`` (the exported code cannot reach the repository through
|
|
165
|
+
git), no attribute, eol conversion, ``ident`` or filter driver (unlike a
|
|
166
|
+
checkout, whose bytes depend on the operator's git configuration and
|
|
167
|
+
which runs the filter programs it names), nothing dropped (unlike ``git
|
|
168
|
+
archive``'s ``export-ignore``), no worktree or branch (nothing for the
|
|
169
|
+
operator to clean up, nothing that collides with their checkout).
|
|
170
|
+
Submodules are not populated: a gitlink is an empty directory. The
|
|
171
|
+
directory is ``EXPORT_PREFIX`` + a random suffix in the system temporary
|
|
172
|
+
directory, and is deleted when the context exits.
|
|
173
|
+
|
|
174
|
+
Raises VerificationError (a :class:`~autoforge.errors.MaterializationError`
|
|
175
|
+
or :class:`~autoforge.errors.CandidateNotMaterializableError` among them)
|
|
176
|
+
when the commit cannot be materialised; the caller decides whether that
|
|
177
|
+
is inconclusive or conclusive.
|
|
178
|
+
"""
|
|
179
|
+
object_format = read_object_format(runner, repo)
|
|
180
|
+
with materialize(runner, repo, sha, object_format, prefix=EXPORT_PREFIX) as tree:
|
|
181
|
+
yield ExportedTree(sha=sha, root=tree.root)
|
autoforge/profiles.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Agent execution profile routing (pure, easily tested logic)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .config import AutoForgeConfig, ProfileConfig
|
|
6
|
+
from .errors import ConfigurationError
|
|
7
|
+
from .transitions import Phase
|
|
8
|
+
|
|
9
|
+
REVIEW_ROUND_1 = "review_round_1"
|
|
10
|
+
REVIEW_ROUND_2_5 = "review_round_2_5"
|
|
11
|
+
REVIEW_ROUND_6_PLUS = "review_round_6_plus"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def review_profile_name(round: int) -> str:
|
|
15
|
+
"""Route a 1-based review round to its logical profile name.
|
|
16
|
+
|
|
17
|
+
round == 1 -> review_round_1
|
|
18
|
+
2 <= round <= 5 -> review_round_2_5
|
|
19
|
+
round >= 6 -> review_round_6_plus
|
|
20
|
+
"""
|
|
21
|
+
if round < 1:
|
|
22
|
+
raise ConfigurationError(f"review round must be >= 1, got {round}")
|
|
23
|
+
if round == 1:
|
|
24
|
+
return REVIEW_ROUND_1
|
|
25
|
+
if 2 <= round <= 5:
|
|
26
|
+
return REVIEW_ROUND_2_5
|
|
27
|
+
return REVIEW_ROUND_6_PLUS
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Every profile a REMOTE run can reach, so a missing or unusable one is a
|
|
31
|
+
# config-time failure (`ControllerEngine.validate_config`) rather than one
|
|
32
|
+
# discovered mid-run. `doctor` checks the same list, so what it reports as
|
|
33
|
+
# runnable is exactly what a run accepts. `update_epic` is not listed: that
|
|
34
|
+
# phase is still gated (see `config.py`). Keep this in step with the
|
|
35
|
+
# `profile_for_phase` mapping below and `review_profile_name` above.
|
|
36
|
+
REQUIRED_PROFILES = [
|
|
37
|
+
"analyze_execute",
|
|
38
|
+
"fix",
|
|
39
|
+
REVIEW_ROUND_1,
|
|
40
|
+
REVIEW_ROUND_2_5,
|
|
41
|
+
REVIEW_ROUND_6_PLUS,
|
|
42
|
+
"replan_reexecute",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
# The only profile every LOCAL run reaches whatever its bound is. `fix` and
|
|
46
|
+
# the reviewer profiles are conditional (see `local_required_profiles`).
|
|
47
|
+
LOCAL_BASE_PROFILES = ["analyze_execute"]
|
|
48
|
+
|
|
49
|
+
# Lowest review round that routes to each reviewer profile, matching
|
|
50
|
+
# `review_profile_name`. Deriving the requirement from these thresholds keeps
|
|
51
|
+
# `local_required_profiles` O(1): a configured bound of 10**9 fix rounds still
|
|
52
|
+
# reaches exactly three reviewer profiles, so validation must not walk the
|
|
53
|
+
# rounds one by one to discover that.
|
|
54
|
+
REVIEW_PROFILE_THRESHOLDS: tuple[tuple[int, str], ...] = (
|
|
55
|
+
(1, REVIEW_ROUND_1),
|
|
56
|
+
(2, REVIEW_ROUND_2_5),
|
|
57
|
+
(6, REVIEW_ROUND_6_PLUS),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def local_required_profiles(cfg: AutoForgeConfig) -> list[str]:
|
|
62
|
+
"""Profiles a LOCAL run can actually reach, given its configured bound.
|
|
63
|
+
|
|
64
|
+
A local run never replans and never updates an EPIC, so those profiles are
|
|
65
|
+
not required. The rest follows from ``local.max_fix_rounds``:
|
|
66
|
+
|
|
67
|
+
- ``fix`` only when at least one fix round is allowed. With
|
|
68
|
+
``max_fix_rounds == 0`` a review with findings goes straight to BLOCKED
|
|
69
|
+
and FIX is never invoked, so requiring a valid ``fix`` profile would
|
|
70
|
+
reject a perfectly runnable one-pass configuration.
|
|
71
|
+
- the reviewer profiles follow exactly the same round routing as remote:
|
|
72
|
+
with ``local.max_fix_rounds >= 5`` a local run can complete review round
|
|
73
|
+
6 and will ask for ``review_round_6_plus``. Deriving the requirement
|
|
74
|
+
from the configured bound keeps a missing reviewer profile a config-time
|
|
75
|
+
failure instead of one discovered five fix rounds into a run; the
|
|
76
|
+
inverse also holds, so ``max_fix_rounds == 0`` requires only
|
|
77
|
+
``review_round_1``.
|
|
78
|
+
"""
|
|
79
|
+
names = list(LOCAL_BASE_PROFILES)
|
|
80
|
+
if cfg.local.max_fix_rounds > 0:
|
|
81
|
+
names.append("fix")
|
|
82
|
+
rounds = cfg.local.max_review_rounds
|
|
83
|
+
names.extend(name for threshold, name in REVIEW_PROFILE_THRESHOLDS if rounds >= threshold)
|
|
84
|
+
return names
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def profile_for_phase(cfg: AutoForgeConfig, phase: Phase, review_round: int = 0) -> ProfileConfig:
|
|
88
|
+
"""Select the execution profile for a phase (REVIEW uses round routing)."""
|
|
89
|
+
if phase == Phase.REVIEW:
|
|
90
|
+
# review_round stores completed rounds; the upcoming execution is +1.
|
|
91
|
+
upcoming = review_round + 1
|
|
92
|
+
return cfg.profile(review_profile_name(upcoming))
|
|
93
|
+
if phase == Phase.MERGE:
|
|
94
|
+
raise ConfigurationError(
|
|
95
|
+
"phase MERGE has no execution profile: the controller performs the merge "
|
|
96
|
+
"itself (gh pr merge via GitHubClient); agents never merge"
|
|
97
|
+
)
|
|
98
|
+
mapping = {
|
|
99
|
+
Phase.ANALYZE_EXECUTE: "analyze_execute",
|
|
100
|
+
Phase.FIX: "fix",
|
|
101
|
+
Phase.REPLAN_REEXECUTE: "replan_reexecute",
|
|
102
|
+
Phase.UPDATE_EPIC: "update_epic",
|
|
103
|
+
}
|
|
104
|
+
if phase not in mapping:
|
|
105
|
+
raise ConfigurationError(f"phase {phase.value} has no execution profile")
|
|
106
|
+
return cfg.profile(mapping[phase])
|
autoforge/progress.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Live agent progress: a provider-neutral event and its terminal renderer (#192).
|
|
2
|
+
|
|
3
|
+
A provider adapter maps its own protocol (Claude's stream-json, Pi's RPC
|
|
4
|
+
events, OpenCode's stderr) into :class:`ProgressEvent` and hands each one to
|
|
5
|
+
the :data:`ProgressSink` the engine put in the request. The engine and the
|
|
6
|
+
CLI never see a provider record: an event is a closed ``kind``, a tool name,
|
|
7
|
+
one allow-listed ``detail`` and a token estimate, and nothing else. The kinds
|
|
8
|
+
are the subset of #154's shared lifecycle events that a CLI provider can
|
|
9
|
+
report today (started, tool started/finished/failed, provider retry) plus
|
|
10
|
+
the liveness kinds a stream adds (thinking, assistant text, activity); #154
|
|
11
|
+
extends this set rather than defining a second one. One kind is not the
|
|
12
|
+
agent's: ``loop_suspected`` carries the controller's loop detector warning
|
|
13
|
+
(#194, :mod:`autoforge.loop_detect`), is printed as it is and is never
|
|
14
|
+
counted as the agent's activity.
|
|
15
|
+
|
|
16
|
+
Every string an event carries is made safe when the event is built, not
|
|
17
|
+
where it is printed: ANSI escape sequences, control, format and line
|
|
18
|
+
separator characters are removed first, so a secret split by an escape is
|
|
19
|
+
whole again; the text is then redacted; and only then clipped, because a
|
|
20
|
+
secret cut by the clip would no longer match its pattern. A value too long
|
|
21
|
+
to be a path, a pattern or a description (:data:`MAX_RAW_CHARS`) is not
|
|
22
|
+
shown at all. A detail is one of the allow-listed tool inputs an adapter
|
|
23
|
+
names (a file path, a search pattern, a command's *description*), never a
|
|
24
|
+
command line, a tool result, thinking or assistant text.
|
|
25
|
+
|
|
26
|
+
:class:`ProgressReporter` turns events into lines for the operator. Each
|
|
27
|
+
line is prefixed with the time elapsed since the launch and a label (phase
|
|
28
|
+
and issue), is redacted again as a whole, and goes to every output: the
|
|
29
|
+
step's ``progress.log`` and, when the CLI asks for it, stderr. Thinking and
|
|
30
|
+
assistant text are coalesced to one line per :data:`THINKING_INTERVAL_SECONDS`;
|
|
31
|
+
a finished tool and bare activity print nothing; and when nothing has been
|
|
32
|
+
printed for :data:`HEARTBEAT_AFTER_SECONDS` a heartbeat line says how long
|
|
33
|
+
ago the agent last did anything. The reporter never raises into the
|
|
34
|
+
adapter or the engine: an output that fails is dropped and never called
|
|
35
|
+
again, with one line saying so on the others; an output that fails while
|
|
36
|
+
receiving that line is dropped in turn.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import enum
|
|
42
|
+
import re
|
|
43
|
+
import threading
|
|
44
|
+
import time
|
|
45
|
+
import unicodedata
|
|
46
|
+
from collections.abc import Callable, Sequence
|
|
47
|
+
from dataclasses import dataclass, field
|
|
48
|
+
from types import TracebackType
|
|
49
|
+
|
|
50
|
+
from .redaction import redact
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ProgressKind(enum.StrEnum):
|
|
54
|
+
"""What happened. Closed: an adapter maps anything else to ``ACTIVITY``."""
|
|
55
|
+
|
|
56
|
+
STARTED = "started"
|
|
57
|
+
THINKING = "thinking"
|
|
58
|
+
ASSISTANT_TEXT = "assistant_text"
|
|
59
|
+
TOOL_STARTED = "tool_started"
|
|
60
|
+
TOOL_FINISHED = "tool_finished"
|
|
61
|
+
TOOL_FAILED = "tool_failed"
|
|
62
|
+
PROVIDER_RETRY = "provider_retry"
|
|
63
|
+
# Any other sign of life: a record that is none of the above.
|
|
64
|
+
ACTIVITY = "activity"
|
|
65
|
+
# Not the agent's: the controller's loop detector (#194) warns that the
|
|
66
|
+
# agent may be repeating itself; ``detail`` is its short description.
|
|
67
|
+
LOOP_SUSPECTED = "loop_suspected"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# A raw value longer than this is no path, pattern or description an
|
|
71
|
+
# operator needs to read, and redacting it would cost work proportional to
|
|
72
|
+
# whatever an agent put in a tool input; it is shown as an ellipsis.
|
|
73
|
+
MAX_RAW_CHARS = 4096
|
|
74
|
+
MAX_TOOL_CHARS = 64
|
|
75
|
+
MAX_DETAIL_CHARS = 160
|
|
76
|
+
MAX_LABEL_CHARS = 80
|
|
77
|
+
MAX_LINE_CHARS = 400
|
|
78
|
+
MAX_TOKENS = 10**12
|
|
79
|
+
ELLIPSIS = "…"
|
|
80
|
+
|
|
81
|
+
# ANSI/VT escape sequences: CSI (7-bit and the C1 byte), OSC up to BEL or
|
|
82
|
+
# ST (or the end, when unterminated), DCS/SOS/PM/APC strings, and the
|
|
83
|
+
# two-character escapes. What is left of a broken sequence is removed with
|
|
84
|
+
# the other controls below.
|
|
85
|
+
_ESCAPES = re.compile(
|
|
86
|
+
r"\x1b\[[0-?]*[ -/]*[@-~]"
|
|
87
|
+
r"|\x9b[0-?]*[ -/]*[@-~]"
|
|
88
|
+
r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?"
|
|
89
|
+
r"|\x1b[PX^_][^\x1b]*(?:\x1b\\)?"
|
|
90
|
+
r"|\x1b[ -/]*[0-~]"
|
|
91
|
+
)
|
|
92
|
+
_SPACES = re.compile(r"\s+")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def clean(value: object, limit: int) -> str:
|
|
96
|
+
"""``value`` as one safe, bounded line: stripped, redacted, then clipped.
|
|
97
|
+
|
|
98
|
+
A non-string is empty; a string longer than :data:`MAX_RAW_CHARS` is an
|
|
99
|
+
ellipsis. Escape sequences go first, then every control (C0, C1, DEL),
|
|
100
|
+
format (bidirectional overrides, zero-width characters) and line or
|
|
101
|
+
paragraph separator character; whitespace controls become spaces. The
|
|
102
|
+
result is redacted and only then clipped to ``limit`` characters.
|
|
103
|
+
"""
|
|
104
|
+
if not isinstance(value, str) or limit <= 0:
|
|
105
|
+
return ""
|
|
106
|
+
if len(value) > MAX_RAW_CHARS:
|
|
107
|
+
return ELLIPSIS
|
|
108
|
+
text = _ESCAPES.sub("", value)
|
|
109
|
+
kept: list[str] = []
|
|
110
|
+
for ch in text:
|
|
111
|
+
category = unicodedata.category(ch)
|
|
112
|
+
if category == "Cc":
|
|
113
|
+
if ch in "\t\n\r\v\f":
|
|
114
|
+
kept.append(" ")
|
|
115
|
+
elif category in ("Zl", "Zp"):
|
|
116
|
+
kept.append(" ")
|
|
117
|
+
elif category == "Cs":
|
|
118
|
+
kept.append("�")
|
|
119
|
+
elif category != "Cf":
|
|
120
|
+
kept.append(ch)
|
|
121
|
+
text = _SPACES.sub(" ", redact("".join(kept))).strip()
|
|
122
|
+
if len(text) > limit:
|
|
123
|
+
text = text[: limit - 1].rstrip() + ELLIPSIS
|
|
124
|
+
return text
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class ProgressEvent:
|
|
129
|
+
"""One provider-neutral progress event, made safe on construction.
|
|
130
|
+
|
|
131
|
+
``tool`` is the tool's name; ``detail`` is the one allow-listed input
|
|
132
|
+
the adapter chose for it; ``tokens`` an adapter's token estimate (0 when
|
|
133
|
+
it has none); ``at`` the monotonic time the adapter saw it.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
kind: ProgressKind
|
|
137
|
+
tool: str = ""
|
|
138
|
+
detail: str = ""
|
|
139
|
+
tokens: int = 0
|
|
140
|
+
at: float = field(default_factory=time.monotonic)
|
|
141
|
+
|
|
142
|
+
def __post_init__(self) -> None:
|
|
143
|
+
object.__setattr__(self, "kind", ProgressKind(self.kind))
|
|
144
|
+
object.__setattr__(self, "tool", clean(self.tool, MAX_TOOL_CHARS))
|
|
145
|
+
object.__setattr__(self, "detail", clean(self.detail, MAX_DETAIL_CHARS))
|
|
146
|
+
tokens = self.tokens
|
|
147
|
+
if isinstance(tokens, bool) or not isinstance(tokens, int) or tokens < 0:
|
|
148
|
+
tokens = 0
|
|
149
|
+
object.__setattr__(self, "tokens", min(tokens, MAX_TOKENS))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
ProgressSink = Callable[[ProgressEvent], None]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def guarded(sink: ProgressSink | None) -> ProgressSink | None:
|
|
156
|
+
"""``sink`` wrapped so that it can never fail the invocation reporting to it.
|
|
157
|
+
|
|
158
|
+
The first exception the sink raises drops it for the rest of the
|
|
159
|
+
invocation; progress is observability, never part of the outcome.
|
|
160
|
+
"""
|
|
161
|
+
if sink is None:
|
|
162
|
+
return None
|
|
163
|
+
live = [sink]
|
|
164
|
+
|
|
165
|
+
def emit(event: ProgressEvent) -> None:
|
|
166
|
+
if not live:
|
|
167
|
+
return
|
|
168
|
+
try:
|
|
169
|
+
live[0](event)
|
|
170
|
+
except Exception:
|
|
171
|
+
live.clear()
|
|
172
|
+
|
|
173
|
+
return emit
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def relative_path(path: object, cwd: str) -> str:
|
|
177
|
+
"""``path`` relative to ``cwd`` when it lies under it; ``""`` when it is no string."""
|
|
178
|
+
if not isinstance(path, str):
|
|
179
|
+
return ""
|
|
180
|
+
if cwd:
|
|
181
|
+
base = cwd.rstrip("/") + "/"
|
|
182
|
+
if path.startswith(base) and len(path) > len(base):
|
|
183
|
+
return path[len(base) :]
|
|
184
|
+
return path
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
THINKING_INTERVAL_SECONDS = 30.0
|
|
188
|
+
HEARTBEAT_AFTER_SECONDS = 120.0
|
|
189
|
+
HEARTBEAT_POLL_SECONDS = 5.0
|
|
190
|
+
# How long closing the reporter waits for its heartbeat thread.
|
|
191
|
+
_JOIN_SECONDS = 10.0
|
|
192
|
+
|
|
193
|
+
Output = Callable[[str], None]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _clock_text(seconds: float) -> str:
|
|
197
|
+
total = max(0, int(seconds))
|
|
198
|
+
hours, rest = divmod(total, 3600)
|
|
199
|
+
minutes, secs = divmod(rest, 60)
|
|
200
|
+
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _ago(seconds: float) -> str:
|
|
204
|
+
total = max(0, int(seconds))
|
|
205
|
+
if total < 60:
|
|
206
|
+
return f"{total}s"
|
|
207
|
+
if total < 3600:
|
|
208
|
+
return f"{total // 60}m{total % 60:02d}s"
|
|
209
|
+
return f"{total // 3600}h{total % 3600 // 60:02d}m"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _tokens_text(tokens: int) -> str:
|
|
213
|
+
if tokens >= 1000:
|
|
214
|
+
return f"~{tokens // 1000}k tokens"
|
|
215
|
+
return f"~{tokens} tokens"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class ProgressReporter:
|
|
219
|
+
"""Renders one invocation's progress events as prefixed lines.
|
|
220
|
+
|
|
221
|
+
Use as a context manager around the launch: entering starts the
|
|
222
|
+
heartbeat thread, leaving stops and joins it, so nothing it starts
|
|
223
|
+
outlives the invocation. :meth:`sink` is the :data:`ProgressSink` handed
|
|
224
|
+
to the adapter and may be called from any thread; lines are written
|
|
225
|
+
under one lock, in order, to every output.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
def __init__(
|
|
229
|
+
self,
|
|
230
|
+
label: str,
|
|
231
|
+
outputs: Sequence[Output],
|
|
232
|
+
*,
|
|
233
|
+
clock: Callable[[], float] = time.monotonic,
|
|
234
|
+
thinking_interval: float = THINKING_INTERVAL_SECONDS,
|
|
235
|
+
heartbeat_after: float = HEARTBEAT_AFTER_SECONDS,
|
|
236
|
+
poll_seconds: float = HEARTBEAT_POLL_SECONDS,
|
|
237
|
+
) -> None:
|
|
238
|
+
self._label = clean(label, MAX_LABEL_CHARS)
|
|
239
|
+
self._outputs = list(outputs)
|
|
240
|
+
self._clock = clock
|
|
241
|
+
self._thinking_interval = thinking_interval
|
|
242
|
+
self._heartbeat_after = heartbeat_after
|
|
243
|
+
self._poll_seconds = poll_seconds
|
|
244
|
+
self._lock = threading.Lock()
|
|
245
|
+
self._stop = threading.Event()
|
|
246
|
+
self._thread: threading.Thread | None = None
|
|
247
|
+
self._start = clock()
|
|
248
|
+
self._last_printed = self._start
|
|
249
|
+
self._last_event: float | None = None
|
|
250
|
+
self._last_quiet_line: float | None = None
|
|
251
|
+
self._events = 0
|
|
252
|
+
self._tokens = 0
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def events(self) -> int:
|
|
256
|
+
"""How many events the sink has received."""
|
|
257
|
+
return self._events
|
|
258
|
+
|
|
259
|
+
def __enter__(self) -> ProgressReporter:
|
|
260
|
+
thread = threading.Thread(target=self._heartbeat, name="autoforge-progress", daemon=True)
|
|
261
|
+
try:
|
|
262
|
+
thread.start()
|
|
263
|
+
except RuntimeError:
|
|
264
|
+
# The system refused a thread: the launch goes on without a
|
|
265
|
+
# heartbeat, since progress never decides whether an agent runs.
|
|
266
|
+
self.line("no heartbeat: the progress thread could not be started")
|
|
267
|
+
return self
|
|
268
|
+
self._thread = thread
|
|
269
|
+
return self
|
|
270
|
+
|
|
271
|
+
def __exit__(
|
|
272
|
+
self,
|
|
273
|
+
exc_type: type[BaseException] | None,
|
|
274
|
+
exc: BaseException | None,
|
|
275
|
+
tb: TracebackType | None,
|
|
276
|
+
) -> None:
|
|
277
|
+
self.close()
|
|
278
|
+
|
|
279
|
+
def close(self) -> None:
|
|
280
|
+
"""Stop the heartbeat thread and wait for it. Idempotent."""
|
|
281
|
+
self._stop.set()
|
|
282
|
+
thread, self._thread = self._thread, None
|
|
283
|
+
if thread is not None and thread is not threading.current_thread():
|
|
284
|
+
thread.join(_JOIN_SECONDS)
|
|
285
|
+
|
|
286
|
+
def line(self, text: str) -> None:
|
|
287
|
+
"""Print one controller line (the pre-launch facts, the end) with the prefix.
|
|
288
|
+
|
|
289
|
+
The text is cleaned like an event's detail, since it can quote a
|
|
290
|
+
provider's failure reason or a path. Never raises: progress does not
|
|
291
|
+
decide whether an agent runs or what its result is.
|
|
292
|
+
"""
|
|
293
|
+
try:
|
|
294
|
+
with self._lock:
|
|
295
|
+
self._emit(clean(text, MAX_LINE_CHARS), self._clock())
|
|
296
|
+
except Exception: # pragma: no cover - _emit already contains output failures
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
def sink(self, event: ProgressEvent) -> None:
|
|
300
|
+
"""Take one event from the adapter. Never raises."""
|
|
301
|
+
try:
|
|
302
|
+
with self._lock:
|
|
303
|
+
self._take(event, self._clock())
|
|
304
|
+
except Exception: # pragma: no cover - _emit already contains output failures
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
def poll(self) -> None:
|
|
308
|
+
"""Print a heartbeat when nothing has been printed for the heartbeat interval."""
|
|
309
|
+
with self._lock:
|
|
310
|
+
now = self._clock()
|
|
311
|
+
if now - self._last_printed < self._heartbeat_after:
|
|
312
|
+
return
|
|
313
|
+
if self._last_event is None:
|
|
314
|
+
text = "still running, no activity yet"
|
|
315
|
+
else:
|
|
316
|
+
text = (
|
|
317
|
+
f"still running, last activity {_ago(now - self._last_event)} ago, "
|
|
318
|
+
f"{self._events} events"
|
|
319
|
+
)
|
|
320
|
+
self._emit(text, now)
|
|
321
|
+
|
|
322
|
+
# -- internals -----------------------------------------------------------
|
|
323
|
+
def _heartbeat(self) -> None:
|
|
324
|
+
while not self._stop.wait(self._poll_seconds):
|
|
325
|
+
try:
|
|
326
|
+
self.poll()
|
|
327
|
+
except Exception: # pragma: no cover - _emit already contains output failures
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
def _take(self, event: ProgressEvent, now: float) -> None:
|
|
331
|
+
if event.kind is ProgressKind.LOOP_SUSPECTED:
|
|
332
|
+
# The controller's own observation, not a sign of the agent's life.
|
|
333
|
+
self._emit(event.detail or "possible loop", now)
|
|
334
|
+
return
|
|
335
|
+
self._events += 1
|
|
336
|
+
self._last_event = now
|
|
337
|
+
kind = event.kind
|
|
338
|
+
if kind in (ProgressKind.THINKING, ProgressKind.ASSISTANT_TEXT):
|
|
339
|
+
if event.tokens:
|
|
340
|
+
self._tokens = event.tokens
|
|
341
|
+
last = self._last_quiet_line
|
|
342
|
+
if last is not None and now - last < self._thinking_interval:
|
|
343
|
+
return
|
|
344
|
+
self._last_quiet_line = now
|
|
345
|
+
if kind is ProgressKind.ASSISTANT_TEXT:
|
|
346
|
+
self._emit("writing…", now)
|
|
347
|
+
elif self._tokens:
|
|
348
|
+
self._emit(f"thinking… {_tokens_text(self._tokens)}", now)
|
|
349
|
+
else:
|
|
350
|
+
self._emit("thinking…", now)
|
|
351
|
+
elif kind is ProgressKind.TOOL_STARTED:
|
|
352
|
+
self._emit(f"{event.tool or 'tool'} {event.detail}".rstrip(), now)
|
|
353
|
+
elif kind is ProgressKind.TOOL_FAILED:
|
|
354
|
+
self._emit(f"{event.tool or 'tool'} failed", now)
|
|
355
|
+
elif kind is ProgressKind.STARTED:
|
|
356
|
+
self._emit(f"agent started ({event.detail})" if event.detail else "agent started", now)
|
|
357
|
+
elif kind is ProgressKind.PROVIDER_RETRY:
|
|
358
|
+
self._emit(
|
|
359
|
+
f"provider retry ({event.detail})" if event.detail else "provider retry", now
|
|
360
|
+
)
|
|
361
|
+
# TOOL_FINISHED and ACTIVITY are liveness only: counted, not printed.
|
|
362
|
+
|
|
363
|
+
def _emit(self, text: str, now: float) -> None:
|
|
364
|
+
prefix = f"[{_clock_text(now - self._start)} {self._label}]"
|
|
365
|
+
line = redact(f"{prefix} {text}")
|
|
366
|
+
if len(line) > MAX_LINE_CHARS:
|
|
367
|
+
line = line[: MAX_LINE_CHARS - 1] + ELLIPSIS
|
|
368
|
+
self._last_printed = now
|
|
369
|
+
failed = self._deliver(line)
|
|
370
|
+
# Each dropped output is noted on the ones still live; one that fails
|
|
371
|
+
# receiving a note is dropped and noted in turn, until none fails.
|
|
372
|
+
while failed:
|
|
373
|
+
error = failed.pop(0)
|
|
374
|
+
note = redact(
|
|
375
|
+
f"{prefix} progress output dropped: "
|
|
376
|
+
f"{type(error).__name__}: {clean(str(error), MAX_DETAIL_CHARS)}"
|
|
377
|
+
)
|
|
378
|
+
failed.extend(self._deliver(note))
|
|
379
|
+
|
|
380
|
+
def _deliver(self, line: str) -> list[Exception]:
|
|
381
|
+
"""Write ``line`` to every live output; drop each that fails and return its errors."""
|
|
382
|
+
kept: list[Output] = []
|
|
383
|
+
failed: list[Exception] = []
|
|
384
|
+
for output in self._outputs:
|
|
385
|
+
try:
|
|
386
|
+
output(line)
|
|
387
|
+
except Exception as exc:
|
|
388
|
+
failed.append(exc)
|
|
389
|
+
else:
|
|
390
|
+
kept.append(output)
|
|
391
|
+
self._outputs = kept
|
|
392
|
+
return failed
|