Repository navigation
feat(harness): read an agent without building it #245
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
khushalsonawat
merged 28 commits into
feat/TH-8804-source-subfolder
from
feat/TH-8951-read-without-build
Nov 10, 2026
Merged
Changes from all commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
519f818
feat(harness): read an agent without building it
khushalsonawat 0d70469
feat(harness): list the agents in a repo without building it
khushalsonawat dff28e6
Merge #225 (TH-8804) at 88c93f99 into feat/TH-8951-read-without-build
khushalsonawat 159c580
fix(harness): keep job input out of a reading's failure, and share wh…
khushalsonawat bd198d5
fix(harness): open a reading's stage as the build opens its own
khushalsonawat ee163b0
fix(harness): pin a reading's failure paths, and mask what reaches it…
khushalsonawat 4b775a0
Merge #245 (TH-8951) at ee163b04 into feat/TH-8950-find-agents
khushalsonawat ecda73f
test(harness): stop resetting a cache the reading helper no longer has
khushalsonawat ab5fc18
fix(harness): keep the free agents on a model error, and let kit faul…
khushalsonawat 71aae2b
refactor(harness): use the shared reading helpers instead of copies
khushalsonawat 2b6b816
fix(harness): keep an agent's folder stable, and read own code first
khushalsonawat 67f682a
docs(harness): say what a build can do with a shared folder, and name…
khushalsonawat 4617eec
fix(harness): keep a saved reading through the hard limit and a cancel
khushalsonawat 4eb45c6
test(harness): name the model-step test for when it really runs
khushalsonawat 45eb82b
fix(harness): log the trace when the cost note after a turn fails
khushalsonawat 884043c
fix(harness): keep describing a paid row past a bad file, and surface…
khushalsonawat 6aeb466
fix(harness): let the walk reach a big repo's agent, and read templat…
khushalsonawat ef177b3
fix(harness): end a hung close before the hard limit instead of a sec…
khushalsonawat 81d16b5
fix(harness): mark a paid row short when a file is over the read size
khushalsonawat b46311a
fix(harness): cap the walk at 40,000 files so its memory stays in the…
khushalsonawat b897034
fix(harness): bound the step after the walk by the clock
khushalsonawat 49c1f4c
fix(harness): leave the document room after the longest stop, and kee…
khushalsonawat 825e35d
Merge #225 (TH-8804) at 76fcb60d into feat/TH-8951-read-without-build
khushalsonawat edb6926
test(harness): a browser reading gets its one look before the refusal
khushalsonawat 4dcea5c
Merge #245 (TH-8951) at edb69263 into feat/TH-8950-find-agents
khushalsonawat af8e86e
fix(harness): cap the import pairs the step after the walk holds
khushalsonawat 4c97485
Merge #251 (TH-8950): list the agents in a repo without building it
khushalsonawat d1f968c
fix(reading): answer Dileep's third review of #245
khushalsonawat File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,362 @@ | ||
| """Read one agent the way a build reads it, and leave its description, with no build after it. | ||
|
|
||
| The platform shows an agent's description before any environment exists, and bills the reading | ||
| from ``cost.json``. So the reading runs the build's own stage and gate, and every way it can end | ||
| leaves the files the platform judges it by: a saved contract is never thrown away and paid for | ||
| again, a timeout is never retried as a crash, and the cost on disk is never marked complete | ||
| while some of the spend may be missing from it. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import functools | ||
| import logging | ||
| import time | ||
| from dataclasses import replace | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
| from . import session | ||
| from .agent_type_check import EXIT_REFUSED | ||
| from .backends import StageDone | ||
| from .cli import guidance_text | ||
| from .failure_file import FAILURE_FILE | ||
| from .job import FailureDomain, HarnessFailure, HarnessJob, ProviderExecutionMode, SourceKind | ||
| from .reading_entry import ( | ||
| EXIT_RETRYABLE, | ||
| READING_JOB_INVALID, | ||
| MaskSecrets, | ||
| ReadingMode, | ||
| limit_seconds, | ||
| reading_failure, | ||
| write_reading_cost, | ||
| write_reading_failure, | ||
| ) | ||
| from .reading_result import ( | ||
| CONTRACT_FILE, | ||
| classify, | ||
| document, | ||
| model_error, | ||
| out_of_turns, | ||
| saved_file, | ||
| ) | ||
| from .session import Stage, StageIdleTimeout | ||
| from .source_folder import refuse_unusable_folder | ||
| from .sources import resolve as resolve_source | ||
| from .understand import SUBMIT_REMINDER, open_stage, opening, reading_settings | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
| logger.addFilter(MaskSecrets()) | ||
|
|
||
| READING_SOURCE_EMPTY = "reading_source_empty" | ||
| AGENT_READING_TIMED_OUT = "agent_reading_timed_out" | ||
| AGENT_READING_CRASHED = "agent_reading_crashed" | ||
|
|
||
| _LIMIT_ENV = "ALK_READING_MAX_DURATION_SECONDS" | ||
| _DEFAULT_SOFT_LIMIT_SECONDS = 1800.0 | ||
| # The hard limit outlasts the soft one by the longest stop (the interrupt, the wait after it and | ||
| # the close: 90 s) and room for the saved contract's document after it, whose framework scan can | ||
| # take well over a minute on a crafted repository, so a reading that stops itself is never cut | ||
| # off by the helper first. | ||
| _HARD_LIMIT_MARGIN_SECONDS = 300.0 | ||
| _INTERRUPT_SECONDS = 10.0 | ||
| _INTERRUPT_WAIT_SECONDS = 60.0 | ||
| # Ours, not the SDK's: its close is shielded and has no bound of its own. With it the decision | ||
| # comes by soft + 90 s, which leaves the rest of the margin to the document. | ||
| _CLOSE_SECONDS = 20.0 | ||
|
|
||
| _COUNTS = ("tokens_in", "tokens_out", "tokens_cached") | ||
|
|
||
|
|
||
| def _soft_limit() -> float: | ||
| # The helper reads the same variable the same way for its own limit. | ||
| return limit_seconds(_LIMIT_ENV, _DEFAULT_SOFT_LIMIT_SECONDS) | ||
|
|
||
|
|
||
| def _check_job(job: HarnessJob) -> HarnessFailure | None: | ||
|
khushalsonawat marked this conversation as resolved.
Resolved
|
||
| if job.agent.mode in (ProviderExecutionMode.PROVIDER_IMPORT, ProviderExecutionMode.CONNECT_ONLY): | ||
| # The build reads these agents from the provider's definition, which a reading never loads. | ||
| return reading_failure( | ||
| FailureDomain.PLATFORM_SYNC, | ||
| READING_JOB_INVALID, | ||
| "A reading without a build reads an agent from its repository only. A " | ||
| f"{job.agent.mode.value} job's agent is defined by its provider, so it needs a build.", | ||
| retryable=False, | ||
| ) | ||
| if job.source.kind in (SourceKind.GITHUB, SourceKind.ARCHIVE): | ||
| return None | ||
| return reading_failure( | ||
| FailureDomain.PLATFORM_SYNC, | ||
| READING_JOB_INVALID, | ||
| f"An agent is read from a GitHub repository or an upload, not from a " | ||
| f"{job.source.kind.value} source.", | ||
| retryable=False, | ||
| ) | ||
|
|
||
|
|
||
| class _ReadingStage(Stage): | ||
| """The build's reading stage, with each turn's own spend kept, and no turn ever replayed.""" | ||
|
|
||
| def __init__( | ||
| self, | ||
| spec: Any, | ||
| *, | ||
| name: str, | ||
| backend: Any, | ||
| running_totals: bool, | ||
| ) -> None: | ||
| # Never replayed after a quiet stream: a replayed turn is paid for again, and a new | ||
| # session would restart Claude's running totals under the baseline the difference is | ||
| # taken from. | ||
| super().__init__(spec, name=name, backend=backend, idle_retries=0) | ||
| self.running_totals = running_totals | ||
| self.done: list[StageDone] = [] | ||
| self.in_flight = False | ||
| self.opened = False | ||
| self._last_cost = 0.0 | ||
| self._last_counts = dict.fromkeys(_COUNTS, 0) | ||
|
|
||
| async def __aenter__(self) -> "_ReadingStage": | ||
| await super().__aenter__() | ||
| self.opened = True | ||
| return self | ||
|
|
||
| def _own(self, reported: StageDone) -> StageDone: | ||
| # Claude reports the session's running total on every result, so adding results as | ||
| # reported would count every earlier turn again. | ||
| if not self.running_totals: | ||
| return reported | ||
| cost = reported.cost_usd | ||
| if cost is not None: | ||
| cost, self._last_cost = max(0.0, cost - self._last_cost), cost | ||
| counts: dict[str, int] = {} | ||
| for key in _COUNTS: | ||
| total = int(getattr(reported, key) or 0) | ||
| counts[key] = max(0, total - self._last_counts[key]) | ||
| self._last_counts[key] = total | ||
| return replace(reported, cost_usd=cost, **counts) | ||
|
|
||
| def _events(self, received: Any, turn: Any) -> list[Any]: | ||
| if not isinstance(received, StageDone): | ||
| return super()._events(received, turn) | ||
| done = self._own(received) | ||
| events = super()._events(done, turn) | ||
| self.done.append(done) | ||
| self.in_flight = False | ||
| return events | ||
|
|
||
| async def say(self, message: str, **kwargs: Any) -> Any: | ||
| self.in_flight = True | ||
| return await super().say(message, **kwargs) | ||
|
|
||
|
|
||
| def _source_has_files(source: Path) -> bool: | ||
| try: | ||
| return source.is_dir() and next(source.iterdir(), None) is not None | ||
| except OSError: | ||
| # Read as empty, so retryable: a source the sandbox can't list yet may be listable next time. | ||
| return False | ||
|
|
||
|
|
||
| def _reminder_due(stage: _ReadingStage, output: Path, stopping: asyncio.Event) -> bool: | ||
| if saved_file(output) or stopping.is_set(): | ||
| return False | ||
| opened = stage.done[-1] if stage.done else None | ||
| # An error flag with no status may have passed; one nudge costs less than a whole retry. | ||
| return opened is None or ( | ||
| opened.api_error_status is None and not out_of_turns(opened) | ||
| ) | ||
|
|
||
|
|
||
| async def _converse( | ||
| stage: _ReadingStage, message: str, output: Path, stopping: asyncio.Event | ||
| ) -> None: | ||
| # The session lives inside this task, so cancelling the task closes it. | ||
| async with stage: | ||
| await stage.say(message) | ||
| if _reminder_due(stage, output, stopping): | ||
| await stage.say(SUBMIT_REMINDER) | ||
|
|
||
|
|
||
| async def _stop( | ||
| stage: _ReadingStage, task: asyncio.Task[None], stopping: asyncio.Event, wait: float | ||
| ) -> None: | ||
| # Set first, so a turn ending now doesn't earn the reminder. | ||
| stopping.set() | ||
| # Its own task, waited on with a bound: wait_for would wait again for a cancel the interrupt | ||
| # swallows. A Ctrl-C here reaches `_read`, which lets it through. | ||
| interrupting = asyncio.ensure_future(stage.interrupt_response()) | ||
| bound = min(wait, _INTERRUPT_SECONDS) | ||
| await asyncio.wait({interrupting}, timeout=bound) | ||
| if not interrupting.done(): | ||
| interrupting.cancel() | ||
| logger.warning("the stop at the time limit was not delivered within %g s", bound) | ||
| elif interrupting.cancelled() or interrupting.exception() is not None: | ||
| # A stop that can't be delivered is still a timeout, not a crash. | ||
| logger.warning( | ||
| "the stop at the time limit was not delivered", | ||
| exc_info=None if interrupting.cancelled() else interrupting.exception(), | ||
| ) | ||
| await asyncio.wait({task}, timeout=wait) | ||
| # Closed before the files are read, so a late tool call is unlikely to save after the | ||
| # decision. The close is bounded, though: a session that hangs closing must not keep the | ||
| # reading from deciding, and one that outlives the bound could still act afterwards. | ||
| task.cancel() | ||
| closing = min(wait, _CLOSE_SECONDS) | ||
| await asyncio.wait({task}, timeout=closing) | ||
| if not task.done(): | ||
| logger.warning("the stopped reading's session did not close within %g s", closing) | ||
| elif not task.cancelled() and task.exception() is not None: | ||
| logger.warning("the stopped reading ended with an error", exc_info=task.exception()) | ||
|
|
||
|
|
||
| def _timed_out(output: Path, soft_limit: float) -> int: | ||
| write_reading_failure( | ||
| reading_failure( | ||
| FailureDomain.INFRASTRUCTURE, | ||
| AGENT_READING_TIMED_OUT, | ||
| f"The agent reading ran past its {soft_limit:g} s limit without saving the agent's " | ||
| "description, and was stopped.", | ||
| retryable=False, | ||
| ), | ||
| output, | ||
| ) | ||
| return EXIT_REFUSED | ||
|
|
||
|
|
||
| async def _reading( | ||
| job: HarnessJob, source: Path, output: Path, started: float, soft_limit: float, wait: float | ||
| ) -> tuple[int, bool]: | ||
| """The exit code, and whether every turn's spend is known to be in the cost file.""" | ||
| # Before the folder check, so an upload that hasn't arrived isn't blamed on the folder. | ||
| if not _source_has_files(source): | ||
| write_reading_failure( | ||
| reading_failure( | ||
| FailureDomain.PLATFORM_SYNC, | ||
| READING_SOURCE_EMPTY, | ||
| "The agent's source was missing or empty, so there was nothing to read. " | ||
| "It may not have arrived yet.", | ||
| retryable=True, | ||
| ), | ||
| output, | ||
| ) | ||
| return EXIT_RETRYABLE, True | ||
| if refuse_unusable_folder(source, job.source.subfolder, output): | ||
| return EXIT_REFUSED, True | ||
|
|
||
| backend = session.resolve() | ||
| agent_source = resolve_source( | ||
| "repo", | ||
| name=str(job.metadata.get("agent_name") or source.name), | ||
| root=source, | ||
| subfolder=job.source.subfolder, | ||
| ) | ||
| # Opened as the build opens its own reading, turns, thinking and setup rule included, so a | ||
| # contract saved here is one the build's gate would have kept. | ||
| opened, _ = open_stage(agent_source, out=output, **reading_settings(job)) | ||
| stage = _ReadingStage( | ||
| opened.spec, | ||
| name=opened.name, | ||
| backend=backend, | ||
| running_totals=getattr(backend, "name", "") == "claude", | ||
| ) | ||
| stopping = asyncio.Event() | ||
| # The build's opening, the job's guidance included: a generic job's runtime advice changes | ||
| # what the contract records. | ||
| message = opening(agent_source) + guidance_text(job.authoring_guidance()) | ||
| task = asyncio.create_task(_converse(stage, message, output, stopping)) | ||
| finished, _ = await asyncio.wait( | ||
| {task}, timeout=max(0.0, started + soft_limit - time.monotonic()) | ||
| ) | ||
|
|
||
| if not finished: | ||
| await _stop(stage, task, stopping, wait) | ||
| # A request cut off by the stop may be billed without being in the last total. | ||
| saved = saved_file(output) | ||
| if saved == FAILURE_FILE: | ||
| return EXIT_REFUSED, False | ||
| if saved == CONTRACT_FILE: | ||
| return document(job, source, output), False | ||
| return _timed_out(output, soft_limit), False | ||
|
|
||
| error = task.exception() | ||
| if error is not None and not stage.opened: | ||
| # Nothing reached the provider, so the fault is the reading's own: a crash, retried once. | ||
| raise error | ||
| if isinstance(error, StageIdleTimeout): | ||
| # The quiet turn's spend never arrived; what was saved before it still stands. | ||
| saved = saved_file(output) | ||
| if saved == FAILURE_FILE: | ||
| return EXIT_REFUSED, False | ||
| if saved == CONTRACT_FILE: | ||
| return document(job, source, output), False | ||
| return ( | ||
| model_error( | ||
| output, | ||
| "The model stopped responding before the agent's description was saved " | ||
| f"({error}).", | ||
| retryable=True, | ||
| ), | ||
| False, | ||
| ) | ||
| # A request cut with no status may still be billed, and its total never reached us. | ||
| complete = ( | ||
| error is None | ||
| and not stage.in_flight | ||
| and not any( | ||
| done.is_error and done.api_error_status is None and not out_of_turns(done) | ||
| for done in stage.done | ||
| ) | ||
| ) | ||
| return classify(job, source, output, stage, error), complete | ||
|
|
||
|
|
||
| async def _read(job: HarnessJob, source: Path, output: Path, *, interrupt_wait: float) -> int: | ||
| # The same instant the helper's hard limit counts from, give or take a few lines. | ||
| started = time.monotonic() | ||
| output = Path(output) | ||
| code, complete = EXIT_RETRYABLE, False | ||
| try: | ||
| try: | ||
| code, complete = await _reading( | ||
| job, Path(source), output, started, _soft_limit(), interrupt_wait | ||
| ) | ||
| except (Exception, asyncio.CancelledError) as exc: | ||
|
khushalsonawat marked this conversation as resolved.
Resolved
|
||
| # A contract the gate saved is paid for; a crash would have the platform pay again. | ||
| # A cancel leaking out of the session is the same: the saved contract still stands. | ||
| # A cancel of this task itself (a Ctrl-C) is let through, the saved files as they are. | ||
| # (3.10 has no `cancelling`, and there a Ctrl-C is raised as itself, not as a cancel.) | ||
| current = asyncio.current_task() | ||
| if saved_file(output) != CONTRACT_FILE or ( | ||
| isinstance(exc, asyncio.CancelledError) | ||
| and getattr(current, "cancelling", lambda: 0)() | ||
| ): | ||
| raise | ||
| logger.warning( | ||
| "reading fault after a saved contract: class=%s", type(exc).__name__, exc_info=True | ||
| ) | ||
| code, complete = document(job, Path(source), output), False | ||
| finally: | ||
| # The work owns the final cost file, on every way out. | ||
| write_reading_cost(output, complete=complete) | ||
| return code | ||
|
|
||
|
|
||
| def _read_agent(*, interrupt_wait: float = _INTERRUPT_WAIT_SECONDS) -> ReadingMode: | ||
| return ReadingMode( | ||
| name="read_agent", | ||
| check_job=_check_job, | ||
| work=functools.partial(_read, interrupt_wait=interrupt_wait), | ||
| # The helper reads the variable when the reading starts, as the work does, so its | ||
| # limit is always the reading's own plus the margin. | ||
| time_limit_seconds=_DEFAULT_SOFT_LIMIT_SECONDS, | ||
| timed_out_code=AGENT_READING_TIMED_OUT, | ||
| timed_out_retryable=False, | ||
| crashed_code=AGENT_READING_CRASHED, | ||
| limit_env=_LIMIT_ENV, | ||
| limit_margin_seconds=_HARD_LIMIT_MARGIN_SECONDS, | ||
| ) | ||
|
|
||
|
|
||
| READ_AGENT = _read_agent() | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.