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.
Files changed (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. 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