-
Notifications
You must be signed in to change notification settings - Fork 1
feat(harbor): feedback levers: failure transcripts, multi-fidelity screening, per-attempt detail #30
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
base: harbor-all-fixes
Are you sure you want to change the base?
feat(harbor): feedback levers: failure transcripts, multi-fidelity screening, per-attempt detail #30
Changes from all commits
fc04cd6
261ef48
8e1d1e9
230add8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,8 +35,25 @@ | |
| class HarborRunner: | ||
| """Mode-B EvalStrategy: nested `harbor run` + collate -> SampleResults.""" | ||
|
|
||
| def __init__(self, config: HarborConfig): | ||
| def __init__( | ||
| self, | ||
| config: HarborConfig, | ||
| *, | ||
| feedback_transcripts: bool = False, | ||
| feedback_max_bytes: int = 3000, | ||
| expose_attempt_detail: bool = False, | ||
| ): | ||
| self.config = config | ||
| # Lever 1: attach the transcript tail of a FAILED sample's trial to its | ||
| # SampleResult.feedback. Whether the agent ever sees it is decided by | ||
| # the sidecar's tier routing (per-sample files are viewable-only), not | ||
| # here; this only controls whether the field is filled at collation. | ||
| self.feedback_transcripts = feedback_transcripts | ||
| self.feedback_max_bytes = feedback_max_bytes | ||
| # Lever 3: attach a per-attempt {reward, exception} list to each | ||
| # sample's output. Same tier gate as feedback: filled at collation, | ||
| # exposed only via the viewable-split per-sample files. | ||
| self.expose_attempt_detail = expose_attempt_detail | ||
|
|
||
| async def produce_sample_results( | ||
| self, | ||
|
|
@@ -174,11 +191,12 @@ def _collate( | |
| f"canonical '<org>/<name>' form; refusing to score all " | ||
| f"samples 0." | ||
| ) | ||
| groups = ( | ||
| self._trial_groups(jobs_dir) | ||
| if self.config.aggregate_attempts == "mean" | ||
| else {} | ||
| need_attempts = ( | ||
| self.config.aggregate_attempts == "mean" | ||
| or self.feedback_transcripts | ||
| or self.expose_attempt_detail | ||
| ) | ||
| groups = self._trial_groups(jobs_dir) if need_attempts else {} | ||
| for sample_id, task_name in pairs: | ||
| if self._is_done(params, sample_id): | ||
| continue # already collated successfully (resume); errors are redone | ||
|
|
@@ -235,7 +253,25 @@ def _trial_groups(self, jobs_dir: Path) -> dict[str, list[dict]]: | |
| task_name = data.get("task_name") | ||
| if not task_name: | ||
| continue | ||
| # Transcripts (agent/terminus_2.pane etc.) live next to result.json; | ||
| # keep the dir so feedback can find them after the path is dropped. | ||
| data["_trial_dir"] = str(result_json.parent) | ||
| groups.setdefault(task_name, []).append(data) | ||
| # rglob order is undefined; sort each group so "first attempt" is a | ||
| # stable notion (feedback uses the first failed attempt's transcript). | ||
| # An attempt with no finished_at must sort LAST, not first: an empty | ||
| # string would sort ahead of every real ISO timestamp and mislabel a | ||
| # timestamp-less attempt as the "first". The leading bool puts present | ||
| # timestamps first (False < True), then orders by the timestamp, and | ||
| # finally tie-breaks on the stable trial_name. | ||
| for attempts in groups.values(): | ||
| attempts.sort( | ||
| key=lambda d: ( | ||
| d.get("finished_at") is None, | ||
| d.get("finished_at") or "", | ||
| d.get("trial_name") or "", | ||
| ) | ||
| ) | ||
| return groups | ||
|
|
||
| @staticmethod | ||
|
|
@@ -273,6 +309,12 @@ def _sample_result( | |
| return SampleResult( | ||
| error=f"No Harbor trial result for task '{task_name}'.", **common | ||
| ) | ||
| attempt_detail = self._attempt_detail(attempts) | ||
|
|
||
| def _out(output: dict) -> dict: | ||
| if attempt_detail is not None: | ||
| output["attempts"] = attempt_detail | ||
| return output | ||
| # Mean aggregation across attempts: average the reward over every SCORED | ||
| # attempt, dirty or clean. Harbor can record an exception (agent timeout, | ||
| # non-zero agent exit) and still run the verifier, so such an attempt | ||
|
|
@@ -281,8 +323,10 @@ def _sample_result( | |
| # two timeouts would score 1.0) and systematically forgives candidates | ||
| # that make the agent slower. Only attempts with no rewards at all | ||
| # (failed before the verifier scored) are excluded. Falls through to the | ||
| # single best trial when nothing scored. | ||
| if attempts: | ||
| # single best trial when nothing scored. `attempts` may also be present | ||
| # under 'best' aggregation (collation loads them for the feedback | ||
| # levers), so the mean path is gated on the config, not their presence. | ||
| if attempts and self.config.aggregate_attempts == "mean": | ||
| scored_trials = [ | ||
| t for t in attempts if (t.get("verifier_result") or {}).get("rewards") | ||
| ] | ||
|
|
@@ -303,36 +347,42 @@ def _sample_result( | |
| f"Task '{task_name}': mean over {len(scored)} scored " | ||
| f"attempt(s) of {self.config.n_attempts} configured." | ||
| ) | ||
| mean = sum(scored) / len(scored) | ||
| return SampleResult( | ||
| score=sum(scored) / len(scored), | ||
| score=mean, | ||
| feedback=self._failure_feedback(mean, attempts), | ||
| metrics={ | ||
| "reward_mean": sum(scored) / len(scored), | ||
| "reward_mean": mean, | ||
| "n_attempts": float(len(attempts)), | ||
| "n_scored": float(len(scored)), | ||
| "n_clean": float(n_clean), | ||
| }, | ||
| output={ | ||
| output=_out({ | ||
| "task_name": task_name, | ||
| "attempt_scores": scored, | ||
| "aggregate": "mean", | ||
| }, | ||
| }), | ||
| **common, | ||
| ) | ||
| rewards = (trial.get("verifier_result") or {}).get("rewards") or {} | ||
| if not rewards: | ||
| return SampleResult( | ||
| error=f"No verifier rewards for task '{task_name}'.", | ||
| output={"task_name": task_name, "trial_name": trial.get("trial_name")}, | ||
| output=_out( | ||
| {"task_name": task_name, "trial_name": trial.get("trial_name")} | ||
| ), | ||
| **common, | ||
| ) | ||
| score = self._extract_reward(rewards) | ||
| return SampleResult( | ||
| score=self._extract_reward(rewards), | ||
| score=score, | ||
| feedback=self._failure_feedback(score, attempts), | ||
| metrics={k: float(v) for k, v in rewards.items()}, | ||
| output={ | ||
| output=_out({ | ||
| "task_name": task_name, | ||
| "trial_name": trial.get("trial_name"), | ||
| "rewards": rewards, | ||
| }, | ||
| }), | ||
| **common, | ||
| ) | ||
|
|
||
|
|
@@ -343,6 +393,93 @@ def _extract_reward(self, rewards: dict) -> float: | |
| values = [float(v) for v in rewards.values()] | ||
| return sum(values) / len(values) if values else 0.0 | ||
|
|
||
| def _attempt_detail(self, attempts: list[dict] | None) -> list[dict] | None: | ||
| """Lever 3: one {reward, exception} entry per attempt, in attempt order | ||
| (sorted at load). reward is None when the attempt died before the | ||
| verifier scored it; exception is the recorded exception class name | ||
| (None for clean attempts). Off (or no attempts loaded) returns None, | ||
| which leaves the output dict without an 'attempts' key at all.""" | ||
| if not self.expose_attempt_detail or not attempts: | ||
| return None | ||
| detail = [] | ||
| for attempt in attempts: | ||
| rewards = (attempt.get("verifier_result") or {}).get("rewards") | ||
| detail.append( | ||
| { | ||
| "reward": self._extract_reward(rewards) if rewards else None, | ||
| "exception": (attempt.get("exception_info") or {}).get( | ||
| "exception_type" | ||
| ), | ||
| } | ||
| ) | ||
| return detail | ||
|
|
||
| def _failure_feedback( | ||
| self, score: float, attempts: list[dict] | None | ||
| ) -> str | None: | ||
| """Lever 1: transcript tail for a failed sample (score 0.0). | ||
|
|
||
| Walks the failed attempts in load order (attempts are sorted at load) | ||
| and returns the FIRST one with a readable transcript tail: the earliest | ||
| failure is the cheapest reproducible one, and one tail per sample bounds | ||
| the payload. A failed attempt with no recorded trial dir, or whose trial | ||
| recorded no transcript, does not end the search: the next failed attempt | ||
| is tried before giving up. Passed samples, and everything with the lever | ||
| off, return None (the field serializes as null either way, so responses | ||
| are byte-identical to before when disabled). | ||
| """ | ||
| if not self.feedback_transcripts or score != 0.0 or not attempts: | ||
| return None | ||
| # feedback_max_bytes <= 0 means "no feedback", never "unbounded": a bare | ||
| # data[-0:] slice would return the WHOLE transcript, so the cap must be | ||
| # positive to emit anything at all. | ||
| if self.feedback_max_bytes <= 0: | ||
| return None | ||
| for attempt in attempts: | ||
| rewards = (attempt.get("verifier_result") or {}).get("rewards") | ||
| if not rewards or self._extract_reward(rewards) != 0.0: | ||
| continue | ||
| trial_dir = attempt.get("_trial_dir") | ||
| if not trial_dir: | ||
| continue | ||
| tail = self._read_transcript_tail(Path(trial_dir)) | ||
| if tail is not None: | ||
| return tail | ||
| return None | ||
|
|
||
| def _read_transcript_tail(self, trial_dir: Path) -> str | None: | ||
| """Last ``feedback_max_bytes`` of the trial's transcript: the terminal | ||
| pane when present, else the trajectory; None (field omitted) when the | ||
| trial recorded neither. | ||
|
|
||
| A non-positive cap emits nothing (matches _failure_feedback's guard). | ||
| The transcript path is confined to the trial dir: a symlinked transcript | ||
| file, or a resolved path that escapes the trial dir, is skipped silently | ||
| so a hostile trial layout cannot exfiltrate files outside its own dir. | ||
| """ | ||
| if self.feedback_max_bytes <= 0: | ||
| return None | ||
| trial_root = trial_dir.resolve() | ||
| for rel in ("agent/terminus_2.pane", "agent/trajectory.json"): | ||
| path = trial_dir / rel | ||
| # Reject symlinks outright, and any path that resolves outside the | ||
| # trial dir, before touching the bytes. | ||
| if path.is_symlink(): | ||
| continue | ||
| try: | ||
| resolved = path.resolve() | ||
| resolved.relative_to(trial_root) | ||
| except (OSError, ValueError): | ||
| continue | ||
| try: | ||
| data = path.read_bytes() | ||
| except OSError: | ||
| continue | ||
| # errors="replace": a multibyte char straddling the cap boundary is | ||
| # rendered as U+FFFD rather than crashing the collation. | ||
| return data[-self.feedback_max_bytes :].decode("utf-8", errors="replace") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis is a comment left during a code review.
Path: vero/src/vero/harbor/runner.py
Line: 480
Comment:
**Empty transcript file silently produces `""` feedback**
`data[-self.feedback_max_bytes :].decode(...)` on an empty file returns `""`. Because the check in `_failure_feedback` is `if tail is not None`, an empty string passes and the function immediately returns `""` as feedback β rather than continuing to try `trajectory.json` (or skipping the attempt altogether). A caller checking `if result.feedback` will treat it as absent, but the JSON serialization will emit `"feedback": ""` instead of `"feedback": null`, which could confuse an optimizer. Adding `if not data: continue` before the decode would skip empty files and let the fallback candidate run.
How can I resolve this? If you propose a fix, please make it concise.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed at the stack tip in bd1d1fe (harbor-9-ops, PR #36 branch): |
||
| return None | ||
|
|
||
| def _existing(self, params: EvaluationParameters, sample_id: int) -> SampleResult | None: | ||
| return load_sample_result( | ||
| get_vero_home_dir() / "sessions", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error branch (trial exists but verifier produced no rewards β agent died before scoring) calls
_out(soexpose_attempt_detailstill populatesoutput["attempts"]), but never calls_failure_feedback, leavingfeedbackat its implicitNone. Since_failure_feedbackonly iterates attempts whose rewards are 0.0, the crash transcript of an attempt that died before the verifier ran is also unreachable through the scored-failure path. Users who setfeedback_transcripts: trueexpressly to understand agent crashes will get no transcript for the most common failure mode (process exit / timeout before the verifier runs).Prompt To Fix With AI
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed at the stack tip in bd1d1fe (harbor-9-ops, PR #36 branch): the no-verifier-rewards error branch now calls
_failure_feedback(0.0, attempts), so a sample whose agent died before scoring carries the crash transcript. You are right that this branch matters: a candidate edit that crashes the agent lands exactly here, and without the transcript the optimizer has no way to see the crash it caused. Regression test:test_no_rewards_error_sample_carries_crash_transcript.