skill_evolution: host integration hooks (analyst fn, incumbent score, richer trajectories) - #395
Conversation
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
…umbent guard Three additive, backward-compatible seams so hosts can adopt the engine as their single evolution implementation instead of forking it: - collect_patches/evolve_skill accept error_analyst_fn, a host-supplied analyst for failure trajectories (e.g. an agentic investigator with tool access). Fleet dispatch, quality gate, and consolidation stay in the engine; success trajectories keep the built-in analyst. - format_trajectory renders richer host evidence when present: verification counts, correction_boundaries (wrong claim vs corrected fact), per-segment execution_sub_trajectories with traces, and a full-session execution_trace. Sessions without these keys render exactly as before. _has_parroted_recovery also honors a parroted outcome in execution_sub_trajectories. - select_candidate/evolve_skill accept incumbent_score so a host that already measured the base skill is not forced to re-score it via score_fn(current_skill) on fresh, noisy traffic. All parameters default to prior behavior; 7 new tests, 41 total pass.
3f1a85a to
c6b7437
Compare
caohy1988
left a comment
There was a problem hiding this comment.
Review at b64cbc1 — approve pending CLA + one code decision
Reviewed the full diff against the engine's existing contracts (this is the follow-up seam extraction to the U6 work in #385). Ran the suite locally: 41/41 pass, including the 7 new tests. CI is green across Python 3.10–3.14.
What verifies well:
- All three seams are genuinely additive — defaults preserve prior behavior, and the existing tests are untouched.
error_analyst_fndispatch only replaces failure-trajectory analysts; host exceptions degrade to warnings through the sharedfut.result()handler, so a host bug can't abort the fleet; the quality gate still applies to host patches — the right invariant.incumbent_scoreusesis not None(a legitimate0.0baseline works), participates in the margin gate, and the artifacts writer never re-scores, so the hook isn't defeated downstream.- The prompt addition (correction-as-hypothesis) is consistent with the anti-parroting evidence from the U6 recording.
_has_parroted_recoveryhonoringexecution_sub_trajectoriescloses the reclassification gap for hosts that segment per correction.
[P1, merge blocker] cla/google is failing: commit b64cbc1 is authored by evekhm-odyssey-bot@users.noreply.github.com, which isn't covered by a CLA (the other commit is evekhm@google.com — that's why #385 passed and this one doesn't). Re-author the bot commit or get the bot account covered.
Two inline items.
| f" {outcome} ---\n" | ||
| ) | ||
| result += (seg.get("trace", "") or "") + "\n\n" | ||
| return result |
There was a problem hiding this comment.
[P2] This early return also skips the execution_trace block below — a host that supplies both execution_sub_trajectories and execution_trace silently loses the full-session trace. The comment above only justifies preferring segment traces over the sub_trajectories outcome list, the PR body presents the two renderings as independent, and no test covers both keys present (test_format_renders_full_session_execution_trace exercises execution_trace alone). Either narrow the early return to skip only the sub_trajectories fallback, or document the precedence here and add a both-keys test.
There was a problem hiding this comment.
Fixed in fa5271b — the precedence is now: per-segment traces suppress ONLY the redundant sub_trajectories outcome list (same labels, no evidence); the full-session execution_trace always renders. Locked with a dual-key test asserting both SEGMENT-TRACE and FULL-SESSION-TRACE appear and the brief list does not.
| the incumbent by at least ``min_improvement``; otherwise keep the base skill. | ||
|
|
||
| ``incumbent_score``, when given, is used as the incumbent's score instead of | ||
| calling ``score_fn(current_skill)``. Hosts that already measured the base |
There was a problem hiding this comment.
[P3 nit] incumbent_score has no effect when score_fn is None (the median-size path wins). Harmless, but worth one docstring line — otherwise a host can pass a baseline and believe the incumbent guard is active when it isn't.
There was a problem hiding this comment.
Fixed in fa5271b, and promoted beyond a docstring line: select_candidate now logs an explicit UNGATED warning when incumbent_score arrives without score_fn (test asserts it), raises ValueError on a non-finite incumbent_score (NaN/-inf/+inf regression tests), and the docstring scopes the guard to the score_fn-present case.
Code review: skill_evolution host integration hooksMulti-perspective review of Verdict: Not ready to merge — blocked on the failing What's solid
FindingsP0 — blocking
P1 — should fix
P2 — recommended
Suggested resolution order
Residual risks (informational, no action required to merge)
Additional test gaps
Review method: 8 parallel specialized reviewers over the PR head checkout, findings cross-corroborated (items #5 and #6 were each flagged by 3 independent lenses), then verified by direct code inspection; test suite re-run locally. Generated with Claude Code |
| result += f"[{icon}] {seg.get('label', '')}{span} -> {outcome}\n" | ||
|
|
||
| # Full-session execution trace (single undivided trace), when captured. | ||
| exec_trace = session.get("execution_trace", "") |
There was a problem hiding this comment.
[P2] Render execution traces for single-turn sessions
This read is inside the if conversation branch, but quality_report.py can emit a supported session with question, response, and execution_trace and no conversation. format_trajectory() then uses the fallback at line 387 and silently omits the trace, so the analyst cannot see routing, tool-call, or tool-error evidence. Share the trace rendering across both session-shape branches and add a question/response + execution_trace regression test.
There was a problem hiding this comment.
Fixed in fa5271b: trace rendering is extracted into a shared _format_execution_trace helper used by both session shapes — a question/response session with execution_trace and no conversation now renders its routing/tool evidence. Regression test included.
…plicitly The engine already enforced it structurally (PARROTING category, behavioral-only patches, the consolidator's no-facts rule) — this makes the principle a named instruction: a user correction proves a gap exists, only a tool proves what is true, and no user-asserted value may enter a patch. Verified corrections learning is the host lab's core method; the measured evidence is its anti-parroting held-out exam (wrong user assertions on unseen topics: V0 53.3% -> evolved 100.0%).
b64cbc1 to
2ec3f21
Compare
…e, contract hardening Guard integrity (findings 3+6): select_candidate rejects non-finite incumbent_score at entry (NaN/inf make every margin comparison False and would silently defeat the never-ship-worse gate) and warns UNGATED when incumbent_score is passed without score_fn; docstring scoped accordingly. Rendering (5+8 + supplemental): per-segment execution traces now suppress only the redundant sub_trajectories outcome list -- the full-session execution_trace still renders alongside them (dual-key test); and single-turn question/response sessions render their execution_trace too, via a shared _format_execution_trace helper (they previously lost all routing/tool evidence silently). Hardening (4, 7, 10): shared _SEGMENT_OUTCOME_ICONS constant; typed ErrorAnalystFn alias on collect_patches and evolve_skill; collect_patches fails fast when client=None outside the hosted error-only mode (built-in analyst futures would otherwise fail and be swallowed after full fleet spend). Docs (2 + nit): evolve_skill's report docstring enumerates the optional enrichment keys with shapes (quality_report.py as reference producer); scripts/README's knob list and example gain incumbent_score and error_analyst_fn with their scoping caveats. Tests (8, 9 + guards): dual-key precedence, single-turn trace, NaN/-inf rejection, UNGATED warning, client fail-fast, and both-mode dispatch (host analyst for failures, built-in for successes).
|
All review findings addressed:
Suite at head: 54 passed (48 + 6 new). Ready for another look. |
caohy1988
left a comment
There was a problem hiding this comment.
Fresh full review at fa5271b. The isolated PR-head test file passes (54 passed), git diff --check is clean, and all current CI checks are green. No P0 or P1 defect survived validation. I left three validated P2 findings inline.
Verdict: Ready with fixes.
Suggested order: preserve partial trajectory evidence, define the host-patch contract, then correct the verification-shape documentation.
| # either way. | ||
| subtraj = session.get("sub_trajectories", []) or [] | ||
| if subtraj: | ||
| if subtraj and not exec_subtraj: |
There was a problem hiding this comment.
[P2] Preserve unmatched correction outcomes
execution_sub_trajectories can be partial because _segment_trace_by_turns() skips individual segments that it cannot align to trace spans. This condition suppresses the entire sub_trajectories list as soon as any execution segment exists, so an unmatched parroted outcome can disappear even though the session is still classified as a failure.
Please render brief entries that have no matching execution segment, or retain the full brief list alongside the traced segments. Add a regression test with two brief outcomes but only one execution segment.
There was a problem hiding this comment.
Fixed in 201394e: brief entries with no traced counterpart (matched on start/end turns) render alongside the traced segments — only covered entries are suppressed. Regression test: two brief outcomes, one execution segment; the uncovered parroted outcome must appear.
| ``conversation`` or ``question``/``response`` per session. Sessions may | ||
| carry optional enrichment keys the analysts render when present (see | ||
| ``scripts/quality_report.py`` for the reference producer): | ||
| ``verifications`` (list of {claim, verified} dicts), |
There was a problem hiding this comment.
[P2] Document the actual verifications shape
The reference producer emits verifications as an integer count (quality_report.py uses ctx.get("verifications", 0)), and format_trajectory() renders it as User verification requests: <count>. This docstring instead tells hosts to supply a list of records, which produces a raw list representation where the analyst prompt expects a count.
Please document this key as an integer count. If detailed verification records are also intended, give them a separate key and render that shape explicitly.
There was a problem hiding this comment.
Fixed in 201394e (also flagged as P1 in the second pass): the docstring now matches the producer/renderer/test — verifications is an int count.
| Args: | ||
| error_analyst_fn: Optional replacement analyst for FAILURE trajectories, | ||
| called as ``fn(client, model, session, current_skill, tools)`` and | ||
| returning patch text or None. Lets a host plug in a richer analyst |
There was a problem hiding this comment.
[P2] Specify the required host-patch envelope
The callback contract promises patch text or None, but every returned value is later filtered by passes_quality_gate(), which also requires a minimum length, a recognized root-cause category, and exact markdown markers such as ## Root Cause plus ## Proposed Patch. A host can satisfy the documented return type and still have every patch silently discarded.
Please document the required envelope, expose or log specific gate-rejection reasons, and add tests for one valid and one malformed host callback result.
There was a problem hiding this comment.
Fixed in 201394e: the envelope is documented on error_analyst_fn (≥50 chars, a ROOT_CAUSE_CATEGORIES token, ## Root Cause/## Pattern + ## Proposed Patch/Content:), gate rejects now log their specific reason via _quality_gate_reason, and tests cover one valid + one malformed host result.
caohy1988
left a comment
There was a problem hiding this comment.
Full review (10 reviewer passes; every finding independently re-verified)
Verdict: ready with fixes. The PR delivers what it claims: all three seams were traced and confirmed additive and backward-compatible (every new format_trajectory branch is gated on key presence and falls through to the prior output; select_candidate's new parameter is appended with a default; no in-repo caller breaks on the new client=None ValueError). All five prior review rounds are verified addressed at fa5271b, and the deferred items are properly tracked in #397 (the issue body matches the deferral list).
9 findings are posted inline: 2 high (one is a one-line docstring fix), 6 moderate, 1 low. Three of them (the non-string-trace crash at :308, the NaN fallback at :919, the malformed-segment crash at :236) are new and not covered by #397 as filed; two others (:809 timeout, :807 non-string patch) overlap #397 items.
Suggested grouping
| Theme | Comments | Resolution |
|---|---|---|
| Host-input hardening at the seam | :809, :807, :308, :236 | Decide once: extend #397 to cover the two new items, or land the cheap isinstance/coercion guards in-PR and leave only the timeout deferred |
| Incumbent-guard consistency | :919, :892 | One shared isfinite helper: validate the computed incumbent in select_candidate, call it early in evolve_skill |
| Standalone | :969 (docstring), :1019 (client-free path reachability), :347 (helper extraction) | :969 is the only one I'd consider merge-gating, and it's one line |
Fix order suggestion: :969 docstring -> :919 + :892 shared helper -> decide the hardening group -> :1019 docs-or-wiring decision -> :347 extraction.
Non-blocking observations
select_candidatelogs the UNGATED warning before the empty-viable check (~:899): withincumbent_scoreset,score_fn=None, and no viable candidates, the log claims median-size selection while the base skill is actually returned.- Exec sub-trajectory segments missing turn bounds render
(turns None-None)(~:372); the siblingsub_trajectoriesrenderer guards withis not None. - A systematically failing host analyst degrades to a silent "No patches to consolidate" no-op visible only in logs (all per-future errors are warnings); same for oversized traces blowing up analyst prompts per-session.
scripts/README.md's enrichment-key summary omitsverificationsandcorrection_boundaries; the CLI section doesn't mention that thescore_fn/incumbent_score/error_analyst_fnfamily is Python-API-only.skill_evolution.pyis now ~1,196 lines (1,047 at base); trajectory formatting would split out cleanly as a sibling module if enrichment keys keep accumulating.
Test coverage worth adding (mostly follow-up material)
- A byte-identical backward-compat test: a legacy session with none of the new keys should produce exactly the pre-change
format_trajectoryoutput (the PR's central claim; current tests assert feature presence only). - Host-analyst failure paths: a raising
error_analyst_fn(swallow-as-warning parity), a non-string return, and a host patch that failspasses_quality_gate(the README promises the gate applies). incumbent_score/error_analyst_fndriven throughevolve_skillitself (only the inner functions are exercised today).- The verifications test passes
2(int) while the docstring says dict list — align with whichever shape :969 settles on.
| ``conversation`` or ``question``/``response`` per session. Sessions may | ||
| carry optional enrichment keys the analysts render when present (see | ||
| ``scripts/quality_report.py`` for the reference producer): | ||
| ``verifications`` (list of {claim, verified} dicts), |
There was a problem hiding this comment.
P1 — docstring contradicts the actual verifications shape. This line documents verifications as a "list of {claim, verified} dicts", but the reference producer (quality_report.py), the renderer (f"User verification requests: {...}" — a bare interpolation), and this PR's own test (verifications=2) all treat it as an int count. A host following the docstring gets a raw Python repr dumped into the analyst prompt.
Suggested: describe it as an int count of user verification requests. (Flagged independently by two review passes; the mismatch is verifiable from the diff alone.)
There was a problem hiding this comment.
Fixed in 201394e — int count, per the producer, renderer, and test.
| ) | ||
| if error_analyst_fn is not None: | ||
| fut = executor.submit( | ||
| error_analyst_fn, client, model, s, current_skill, tools |
There was a problem hiding this comment.
P1 — no timeout on analyst futures now that host callables run in the fleet. fut.result() in the as_completed loop below has no timeout. Before this PR the fleet ran only built-in genai calls; this hook admits arbitrary host callables into the same executor, so an error_analyst_fn that blocks forever hangs collect_patches indefinitely with no diagnostic.
I see #397 already tracks the analyst-future timeout — keeping it deferred is defensible; noting that the exposure grew with this seam. fut.result(timeout=...) with concurrent.futures.TimeoutError handled like any other analyst failure would close it.
There was a problem hiding this comment.
Implemented in 201394e rather than deferred — agreed the exposure grew with this seam. New analyst_timeout_s on collect_patches and evolve_skill (default None): a timed-out future degrades to a warning like any other analyst failure, and the executor shuts down without waiting when a timeout is set, so the hung worker cannot re-hang shutdown. Hung-analyst test included; #397 keeps the broader default-policy question.
| current_skill, | ||
| tools, | ||
| ) | ||
| if error_analyst_fn is not None: |
There was a problem hiding this comment.
P2 — the str-or-None contract is not enforced; a non-string patch crashes after the full fleet spend. Nothing checks the host analyst's return type: a truthy non-string (e.g. {"patch": ...}) passes the if result: append in the collection loop, then passes_quality_gate(p) calls .strip() on it and raises AttributeError outside the try/except that guards fut.result() — an uncaught crash at :842, after the entire fleet's LLM spend. (#397's wording implies a swallowed failure; it's actually uncaught.)
Suggested: append only when isinstance(result, str), logging and dropping anything else.
There was a problem hiding this comment.
Fixed in 201394e: truthy non-string returns are dropped with a warning at collection, before the gate — regression test asserts no post-spend AttributeError and the surviving patch is kept.
| return ( | ||
| "\n=== Execution trace ===\n" | ||
| "Shows agent routing, tool calls, and LLM requests. Look for:" | ||
| " missing tool calls, wrong routing, tool errors.\n\n" + exec_trace + "\n" |
There was a problem hiding this comment.
P2 — a structured execution_trace silently zeroes out the whole run. This concatenation assumes exec_trace is a str. format_trajectory runs inside the analyst futures, so a host supplying a list/dict trace (a plausible mistake — traces are often structured) raises TypeError per session, each swallowed as a warning by the broad except in the collection loop; the run degrades to "No patches to consolidate" and silently returns the unchanged skill.
Suggested: coerce non-strings (json.dumps(exec_trace, default=str)) — or reject loudly before dispatch, matching the spirit of the new client=None fail-fast.
There was a problem hiding this comment.
Fixed in 201394e: _format_execution_trace coerces non-str traces to a JSON dump (default=str, str() fallback) — a structured trace renders instead of zeroing the run. Test with a list-of-dicts trace.
| # Turn-boundary correction evidence, when the host's tagger extracts it: | ||
| # the wrong claim, the user's correction, and whether the agent recovered. | ||
| boundaries = session.get("correction_boundaries", []) or [] | ||
| if boundaries: |
There was a problem hiding this comment.
P2 (structural) — consider extracting these blocks to match the helper convention this diff establishes. The Correction Evidence block here and the Execution sub-trajectories block below are ~12-15 inline lines each, while the same diff adds _format_execution_trace as an extracted helper. Both blocks depend only on session and would extract cleanly (_format_correction_evidence, _format_execution_subtrajectories), keeping format_trajectory from growing linearly as enrichment keys accumulate.
There was a problem hiding this comment.
Done in 201394e: _format_correction_evidence and _format_execution_subtrajectories extracted, matching the _format_execution_trace convention this diff established.
| incumbent = ( | ||
| incumbent_score | ||
| if incumbent_score is not None | ||
| else score_fn(current_skill) |
There was a problem hiding this comment.
P2 — NaN from this fallback still silently disables the incumbent guard. The new finite-check guards the incumbent_score parameter, but this sibling branch is unguarded: a NaN returned by score_fn(current_skill) makes best_score < incumbent + min_improvement evaluate False, and the best candidate ships ungated — exactly the failure mode the ValueError's own message describes.
Suggested: validate the computed incumbent (covering both branches) with the same math.isfinite check.
There was a problem hiding this comment.
Fixed in 201394e: the computed score_fn(current_skill) fallback is finite-checked right after computation and raises with its own message. Regression test: a scorer returning NaN for the base skill.
| nothing clearly improves, leave the already-good skill alone. | ||
| """ | ||
| if incumbent_score is not None: | ||
| if not math.isfinite(incumbent_score): |
There was a problem hiding this comment.
P2 — this check fires only after the full fleet + consolidation spend. select_candidate runs last in evolve_skill, so a host passing NaN/inf incumbent_score burns the entire analyst fleet and best-of-N consolidation before getting this ValueError.
Suggested: hoist into a small shared helper called at the top of evolve_skill — pairs naturally with validating the computed incumbent (see the comment at :919), so one helper resolves both.
There was a problem hiding this comment.
Fixed in 201394e: validation is hoisted into _validate_incumbent_score, called at the very top of evolve_skill (before client creation, so a bad baseline fails before any fleet or consolidation spend) and still by select_candidate for standalone use.
| max_success_samples=max_success_samples, | ||
| analyst_mode=analyst_mode, | ||
| tools=tools, | ||
| error_analyst_fn=error_analyst_fn, |
There was a problem hiding this comment.
P2 (design call) — the client-free path this PR validates is unreachable via evolve_skill. client = client or _make_client(project, location) at :1008 runs before this call, so the client=None + error_analyst_fn + analyst_mode='error-only' combination that collect_patches now explicitly supports can never occur through the public entry point — only by importing collect_patches directly. Meanwhile the README example attaches error_analyst_fn to evolve_skill, which is where hosts will look.
Either skip _make_client when the hosted-error-only combination holds, or state plainly in the README/docstrings that the client-free path is collect_patches-only.
There was a problem hiding this comment.
Documented in 201394e as the design answer: evolve_skill always requires a client because the consolidator uses it regardless of analyst mode — the client-free combination is a standalone-collect_patches surface, and both docstrings now say so explicitly.
| """ | ||
| for key in ("sub_trajectories", "execution_sub_trajectories"): | ||
| for st in session.get(key, []) or []: | ||
| if st.get("outcome") == "parroted": |
There was a problem hiding this comment.
P3 — one malformed segment entry kills the whole report. st.get("outcome") raises AttributeError if a host-supplied execution_sub_trajectories entry isn't a dict, and nothing catches it — partition_trajectories dies before any analysis runs. The unguarded pattern pre-existed for sub_trajectories, but this diff extends it to a new, host-supplied (less-trusted) key.
Suggested: if isinstance(st, dict) and st.get("outcome") == "parroted": so one bad session can't poison the run.
There was a problem hiding this comment.
Fixed in 201394e: _has_parroted_recovery and the renderer both filter non-dict segment entries; a malformed host entry can no longer kill partition_trajectories. Test included.
caohy1988
left a comment
There was a problem hiding this comment.
Second full review pass on fa5271b (10 reviewer lenses, every finding independently re-verified against the head tree). Two results worth recording:
-
Everything previously posted still stands. All 11 outstanding inline comments from the 2026-08-07 reviews re-validated against the current head — including the two on lines this review also converged on independently: the partial
execution_sub_trajectoriessuppression at :386 (confirmed realistic:_segment_trace_by_turnsinquality_report.pyhas multiplecontinuepaths, so a proper-subset segment list occurs with the reference producer itself, and the suppressed brief list then drops parrot/recover evidence for the uncovered segments) and the undocumentedpasses_quality_gateenvelope at :779 (confirmed: the gate requires length >= 50, aROOT_CAUSE_CATEGORIEStoken, and## Root Cause/## Pattern+## Proposed Patch/Content:markers — none documented forerror_analyst_fn, and rejected patches leave only an aggregate count in the logs). No duplicate comments posted for those; this note serves as corroboration on the existing threads. -
Two new items with no existing thread are posted inline below: a missing failure-path test for a raising
error_analyst_fn, and the all-host-futures-failed case degrading to a clean-looking no-op run.
Also re-verified this pass: format_trajectory output is byte-for-byte identical for legacy sessions (both conversation and single-turn shapes) when none of the new keys are present — the PR's backward-compatibility claim holds as stated. A small test locking that parity in would make it durable.
| current_skill, | ||
| tools, | ||
| ) | ||
| if error_analyst_fn is not None: |
There was a problem hiding this comment.
P2 (test gap, no existing thread) — no test covers a raising error_analyst_fn. The engine's tolerance for a failing host analyst rests entirely on the broad except Exception in the collection loop below, but every host-analyst test in tests/test_skill_evolution.py uses a well-behaved analyst — nothing exercises the swallow-and-continue path for this new branch. If that handling regresses (moved, narrowed), nothing catches it, and the failure mode is the engine's worst: a silent zero-patch run.
Suggested test: one collect_patches call where error_analyst_fn raises for one failure session and returns a valid patch for another; assert (via caplog) the warning fires, no exception propagates, and the surviving patch is collected.
There was a problem hiding this comment.
Added in 201394e: test_raising_host_analyst_partial_failure_tolerated — one raising host analyst, one valid; asserts the warning fires via caplog, nothing propagates, and the surviving patch is collected.
| current_skill, | ||
| tools, | ||
| ) | ||
| futures[fut] = ("error", (s.get("question", "") or "")[:60]) |
There was a problem hiding this comment.
P2 (no existing thread) — all host futures failing degrades to a clean-looking no-op evolution. Every raising analyst future — including a systematically broken error_analyst_fn (wrong arity, import error, bad credentials in the host's closure) — hits the same per-future except Exception / logger.warning at :839-840. When ALL host futures fail, collect_patches returns an empty list and evolve_skill logs "No patches to consolidate; returning the current skill" — indistinguishable from a healthy run that genuinely found nothing to patch. The PR's own client=None ValueError exists precisely because this silent-swallow outcome was judged unacceptable for a predictable misconfiguration; a wholly-broken host analyst is the same failure class with no such guard.
Suggested: track per-kind failure counts in the collection loop; when error_analyst_fn is set and every host future raised, raise a RuntimeError naming the first exception (or at minimum logger.error with the aggregate). Partial failures stay tolerated as today.
There was a problem hiding this comment.
Implemented in 201394e: the collection loop tracks host failures, and when error_analyst_fn is set and EVERY host future raised, collect_patches raises RuntimeError naming the first exception — a systematically broken host can no longer masquerade as a healthy zero-patch run. Partial failures stay tolerated (both cases tested).
…dening, guard completeness Rendering: execution_sub_trajectories can be PARTIAL (the reference producer skips segments it cannot align), so brief sub_trajectories entries with no traced counterpart (matched on start/end turns) now render alongside the traced segments -- a parroted outcome can no longer disappear. Structured (non-str) execution_trace values coerce to a JSON dump instead of raising per-session TypeErrors inside analyst futures. Correction-evidence and segment rendering extracted into helpers to match the diff's own convention. Malformed (non-dict) segment entries no longer kill partition_trajectories. Host-analyst contract: truthy non-string returns are dropped with a warning before the gate (no post-spend AttributeError); quality-gate rejects log their specific reason via the new _quality_gate_reason helper; the required patch envelope is documented on error_analyst_fn; when EVERY host call fails, collect_patches raises RuntimeError instead of degrading a broken host into a clean-looking zero-patch run (partial failures stay tolerated); and a new analyst_timeout_s (collect_patches + evolve_skill, default None, cross-ref GoogleCloudPlatform#397) bounds a hung analyst -- the executor shuts down without waiting when a timeout is set. Incumbent guard completeness: validation hoisted into _validate_incumbent_score, called at the top of evolve_skill (fails before any fleet spend) and by select_candidate; the computed score_fn(current_skill) fallback is now finite-checked too. The client-free path is documented as standalone-collect_patches-only (evolve_skill always needs a client for the consolidator). The verifications docstring now matches the producer: an int count. Ten new tests: uncovered-brief-outcome rendering, structured-trace coercion, malformed-segment survival, computed-incumbent NaN, envelope rejection with reason, non-string drop, raising-host partial tolerance, all-host-failure RuntimeError, hung-analyst timeout, and legacy byte-parity (no new sections without new keys). Suite: 64 passed.
|
All 13 findings from the 08-07 and 08-11 passes addressed in
Suite at head: 64 passed (54 + 10 new). Branch is behind |
|
Round-4 verification pass at Verified against code + suite
Three small, non-blocking observations (no action required for merge, your call):
Also noting a design question you may want to consider (not a defect vs. the stated contract): the all-host-failures Nothing here blocks. This round addresses all 13 findings; the residual-risk items tracked in #397 remain the right place for the rest. Branch is |
… per-analyst bound doc, type-violating hosts trip the guard - The UNGATED warning now logs exactly once per evolve run: evolve_skill's early call validates with warn_ungated=False (the raise still fires before any spend) and select_candidate owns the warning. - covered_spans skips traced segments with no turn keys, so a spanless segment cannot blanket-suppress spanless brief entries ((None, None) collision -- theoretical with the reference producer, now total). - analyst_timeout_s docstring states the aggregate bound explicitly: per analyst in submission order, worst case N x timeout for a wholly hung fleet. - Taking the design question the affirmative way: pre-gate type drops now count toward the all-host-failures guard, so a host returning dicts for EVERY session raises RuntimeError instead of degrading into the same clean-looking zero-patch run the guard exists to prevent (the message now says 'failed or returned an unusable result'). Gate-rejected strings still do not count -- that is a quality issue, not a broken host. Three new tests (all-type-violating host raises, spanless-span collision, once-only warning); merged up to current main per the reviewer's timing offer. Suite: 67 passed.
|
Round-4 observations all taken, in
Suite at head: 67 passed; CI matrices green at |
Code review: round 5 (head
|
caohy1988
left a comment
There was a problem hiding this comment.
Review: Approve with nits
Verified locally: checked out the PR head and ran tests/test_skill_evolution.py — 67 tests pass. CI green across Python 3.10–3.14.
This is a well-constructed, genuinely additive PR: all three hooks default to prior behavior, the failure modes a host can induce (broken analyst, hung analyst, NaN baseline, malformed session keys) are each hardened and tested, and the docstrings are unusually honest about residual risks (e.g. the N×timeout worst case). Findings below are all minor — none block merge.
Findings
MINOR — silent behavior change for client=None (scripts/skill_evolution.py:847-855)
Previously collect_patches(report, skill, client=None, ...) returned [] (analyst futures failed with AttributeError, swallowed as warnings); it now raises ValueError. That's the right hardening — the old behavior was broken-by-design — but it contradicts the description's unqualified "all new parameters default to prior behavior." Worth a one-line note in the PR body or README changelog.
MINOR — _format_correction_evidence lacks the malformed-entry guard its siblings got (scripts/skill_evolution.py:302-315)
b.get('turn_index') raises AttributeError on a non-dict entry inside the analyst future, so the session is silently dropped as a warning. sub_trajectories, execution_sub_trajectories, and execution_trace were all hardened against exactly this (isinstance checks at :238, :402, :345); correction_boundaries was missed. Suggest filtering boundaries to dicts, same pattern.
MINOR — rendering asymmetry between session shapes; docstring overclaims (scripts/skill_evolution.py:442-454 vs :392-439)
The single-turn (question/response) path renders execution_trace but not execution_sub_trajectories, correction_boundaries, or verifications, while the evolve_skill docstring (:1098-1107) says the enrichment keys are rendered "when present" with no shape qualification. Either render in both shapes or qualify the docstring ("conversation-shaped sessions").
MINOR — unbounded trace injection into analyst prompts
execution_trace (:353-356) and per-segment trace (:336) are interpolated with no truncation. A host capturing verbose routing/LLM-request traces can multiply prompt size (and Vertex spend) per analyst call. The built-in path has the same exposure via tool_calls_detail, so pre-existing in spirit, but the new keys are the ones most likely to be huge. Suggest a documented cap or truncation note in the session-schema docs.
MINOR — stale PR description
Claims "7 new tests … 41 total pass." The diff actually adds 26 test functions; the file now collects 67 (ran them: 67 passed). The body also omits the two features the round 2–4 hardening commits added: analyst_timeout_s and the all-host-failures RuntimeError guard.
NIT — error_analyst_fn silently ignored with analyst_mode="success-only" (:860-873)
failures is emptied before dispatch, so a caller passing both gets no warning their analyst was never invoked. A one-line logger.warning would match the UNGATED-warning philosophy used for incumbent_score.
NIT — validation polish (:990-995)
_validate_incumbent_score raises for a non-finite score even when score_fn is None (score unused on that path — a warning would suffice); math.isfinite("0.9") raises the interpreter's generic TypeError, not the helpful message — a type check first would polish it.
NIT — cosmetic (turns None-None) (:332-334)
Traced-segment headers don't guard spanless segments the way the brief renderer does (span = "" at :431-435).
NIT — agent_recovered defaults to False (:313)
Prints "Agent recovered: False" for "unknown" when the key is absent; b.get('agent_recovered') rendering None would be less misleading.
Positives
- Exceptional failure-mode engineering: type-violating host returns counted toward the all-failures guard (:907-917),
RuntimeErrorinstead of a silent zero-patch run (:939-946),executor.shutdown(wait=…, cancel_futures=True)so a hung analyst can't re-hang shutdown (:934-937), non-finite incumbent rejected before any model spend (:1141). - Real edge-case tests: the
(None, None)span-collision suppression bug, partial traced-segment coverage, malformed segment entries, hung-analyst timeout, and a legacy-render parity test that directly substantiates the backward-compat claim. - Filing #397 to track residual follow-ups rather than ballooning this diff — good hygiene.
Review by @caohy1988's assistant (Kimi Code CLI).
Code review: current head
|
| # | Where | Finding |
|---|---|---|
| 1 | scripts/skill_evolution.py:1054 |
Candidate scores are not checked with math.isfinite. A score_fn returning positive infinity for a candidate selects it and passes the improvement guard, even though non-finite incumbent scores are rejected to protect the same restraint property. Validate every candidate score and add NaN/infinity tests. |
| 2 | scripts/skill_evolution.py:901-937 |
Timed-out callbacks keep their executor slots. With max_workers=1, one blocking callback prevents a healthy queued callback from starting; both futures time out and the function raises a false all-host-failures error. In analyst_mode="both", a queued built-in success analyst can also be omitted. Use a cancellable/quarantined execution boundary with replacement capacity and add one-worker saturation tests. |
| 3 | scripts/skill_evolution.py:907 |
Falsy non-string callback results (False, 0, [], {}) skip the type check and look like valid no-patch results. Treat only None as the valid empty sentinel; count every other non-string as a host failure. |
P2 - recommended
| # | Where | Finding |
|---|---|---|
| 4 | scripts/skill_evolution.py:309,336 |
Optional malformed evidence can discard an otherwise valid trajectory. A dict/list segment trace raises TypeError; a non-dict correction boundary raises AttributeError. Coerce structured trace values and filter malformed nested entries instead of failing the analyst job. |
| 5 | scripts/skill_evolution.py:442-454 |
The single-turn question/response branch renders only execution_trace; it drops verifications, correction_boundaries, sub_trajectories, and execution_sub_trajectories. Use one shared enrichment renderer for both supported session shapes. |
Validation and residual risk
- Reproduced Add streaming evaluation, Dashboard V2, design docs, and CI workflow #1 with
score_fn(candidate) == float("inf"). - Reproduced Revamp README, enhance documentation navigation, and fix CI #2 with one blocked and one healthy callback at
max_workers=1. - Reproduced Revise README for clarity and updated link #3 with an all-
Falsehost callback. - Reproduced Overhaul README, add documentation indexes, and fix CI issues #4 with a dict segment trace and a
Nonecorrection boundary. - Reproduced docs: add ontology and context graph learning guide #5 with an enriched single-turn session.
- Prior actionable review feedback remains addressed at this head.
- Timed-out worker threads can still outlive
collect_patchesand delay process exit. This is documented, but remains an operational risk.
Suggested order: timeout scheduling (#2) -> selection/result guard integrity (#1, #3) -> shared defensive evidence rendering (#4, #5).
|
Open-PR sweep recheck at unchanged head No commits have landed since my full current-head review, and the current CI checks remain green. The existing Not ready verdict and findings still apply, especially the three P1 correctness issues around non-finite candidate scores, executor starvation after callback timeouts, and falsy non-string callback results. Full current-head review: #395 (comment) I am linking instead of duplicating the complete finding body on the same head. |
caohy1988
left a comment
There was a problem hiding this comment.
Requesting changes at exact head 5cb50a2. The focused suite passes (67 tests) and current CI is green, but three P1 correctness/reliability defects remain:
scripts/skill_evolution.py:1054validates only the incumbent score for finiteness. A candidate score of+infcan win and pass the margin gate, whileNaNis silently skipped. Validate every candidate score and test all non-finite cases.scripts/skill_evolution.py:907checksif result and not isinstance(result, str), so falsy non-string host results (False,0,{},[]) are treated as healthy no-patch results and can bypass the all-host-failures guard. Treat every non-None, non-string result as a host failure.scripts/skill_evolution.py:867-937uses a bounded executor and waits on futures in submission order. Withmax_workers=1, a first hung callback keeps the only worker after its wait times out; queued healthy callbacks never start and then time out too. That breaks the documented partial-failure tolerance. Add a hung-first/healthy-second regression test and use an execution boundary or scheduling strategy that preserves capacity after timeout.
These findings were independently revalidated on the current head.
…e candidate scores) + P2 rendering parity
Round-6 review findings, all five addressed:
P1-1 select_candidate: every candidate score is now checked with
math.isfinite and a non-finite score raises ValueError -- an inf
candidate could previously win selection AND pass the improvement
gate that already rejects non-finite incumbent scores.
P1-2 collect_patches: dispatch moves from ThreadPoolExecutor to
_AnalystCall -- one daemon thread per call gated by a shared
semaphore. A timed-out call is quarantined: it immediately loses its
concurrency slot to the next queued analyst, so with max_workers=1 a
hung callback can no longer starve queued analysts into a false
all-host-failures RuntimeError, and in analyst_mode='both' a queued
built-in success analyst is no longer silently omitted. Quarantined
threads are daemons, so they also cannot delay process exit.
P1-3 collect_patches: None is now the ONLY valid no-patch sentinel;
falsy non-string results (False, 0, [], {}) count as contract
violations toward the all-host-failures guard instead of
masquerading as healthy no-patch results.
P2-4 rendering: structured (dict/list) segment traces coerce to a
readable dump via the shared _coerce_trace_text helper, and non-dict
correction-boundary entries are filtered -- optional malformed
evidence no longer discards an otherwise valid trajectory.
P2-5 rendering: both session shapes share one enrichment renderer
(_format_session_enrichments); the single-turn question/response
branch no longer drops verifications, correction evidence,
sub-trajectory outcomes, or per-segment traces.
Adds 8 tests: one-worker starvation (host-only and 'both' mode),
falsy-sentinel guard, all-None healthy run, non-finite candidate
scores, structured segment traces, malformed boundaries, and
single-turn enrichment parity.
|
All five round-6 findings addressed in P1-1 — non-finite candidate scores ( P1-2 — executor starvation after callback timeouts ( P1-3 — falsy non-string results ( P2-4 — malformed optional evidence P2-5 — single-turn branch drops evidence Verification at |
caohy1988
left a comment
There was a problem hiding this comment.
Requesting changes at exact head c4af0db.
The prior round's candidate-score, falsy non-string, and queued-starvation defects are fixed, the focused suite passes (75 tests), git diff --check is clean, and current CI is green. The replacement timeout scheduler introduces one new P1 reliability defect and three related P2 defects:
- P1 - quarantine no longer enforces
max_workers(scripts/skill_evolution.py:891-893). On timeout,wait()releases the semaphore while the callback is still executing. Each replacement can therefore run concurrently with every quarantined callback. An exact-head reproduction withmax_workers=1, 20 callbacks, and a short timeout observed 18 callbacks executing at once. This can turn slow analysts into an external-service fan-out rather than a bounded fleet. - P2 - one thread is allocated per trajectory (
scripts/skill_evolution.py:863,966-984). Every_AnalystCallstarts a daemon thread before the semaphore gate. A 300-session exact-head reproduction produced 302 process threads withmax_workers=1; report size, notmax_workers, bounds thread allocation. - P2 -
max_workers=0now hangs (scripts/skill_evolution.py:964).BoundedSemaphore(0)is accepted, every call blocks inacquire(), and the defaultwait(None)never returns. The previousThreadPoolExecutorfailed fast for this configuration. Validate a positive integral worker count before dispatch. - P2 - empty host strings bypass the sentinel contract (
scripts/skill_evolution.py:1011-1037). The docs sayNoneis the only healthy no-patch sentinel, but""is silently skipped and whitespace-only strings are gate-rejected without counting as an unusable host result. Handle these explicitly and add all-empty/all-whitespace tests.
The independent validator confirmed all four findings. Please use a bounded dispatch/cancellation policy that measures actual active callbacks and live threads, not only semaphore tokens, then add scale/concurrency regressions.
…_workers validation, blank-string sentinel) - P1: quarantine no longer releases capacity unboundedly. Slot hand-offs from timed-out running analysts go through a donation budget of max_workers, so live analyst callables never exceed 2 x max_workers regardless of report size; further timeouts keep their slot until the callable actually returns. - P2: threads are created lazily by a dispatcher thread AFTER acquiring a slot -- thread allocation is bounded by max_workers, not one daemon thread per trajectory. - P2: max_workers is validated as a positive int before any dispatch (and hoisted to the top of evolve_skill, before client creation), instead of BoundedSemaphore(0) hanging every analyst in acquire(). - P2: empty and whitespace-only host strings are contract violations, not silent skips or quality-gate rejects: warned, dropped, and counted toward the all-host-failures guard (None stays the only no-patch sentinel). 6 new regression tests (bounded concurrency under mass timeout, thread allocation vs report size, worker-count validation in collect_patches and evolve_skill, all-blank guard, blank partial failure); 81 total pass.
|
All four round-7 findings are addressed in 1. P1 — quarantine now enforces a hard concurrency bound. Timeouts can no longer fan the fleet out one replacement per quarantined call. Slot hand-offs go through a donation budget in the new 2. P2 — lazy dispatch; threads bounded by workers, not report size. 3. P2 — 4. P2 — blank strings no longer bypass the sentinel contract. Suite: 81 tests pass (75 prior + 6 new), |
caohy1988
left a comment
There was a problem hiding this comment.
Fresh review of 6378d7ba8a3ce33712dac90224b91b1d6dae86b1: no new actionable findings; approving.
I reviewed host callback dispatch and result validation, finite incumbent/candidate scoring, enriched trajectory rendering, and the lazy daemon-thread dispatcher with bounded quarantine donations. The prior reported candidate-score, sentinel, starvation, concurrency-budget, lazy-thread-allocation, and worker-count issues are addressed in this head. The focused suite passes: 81 tests, Python 3.13.5; current CI checks completed successfully or were skipped.
I also exercised the real #472 adapter against both engine versions. That exposed a caller-side scoring defect in #472: it supplies a production-report baseline while candidates are measured on a different eval set. The finding is posted on #472; it is not a defect in this PR's documented premeasured-score behavior.
Coverage limits: no live model or cloud calls. The timeout is deliberately applied per analyst in submission order, and timed-out daemon callbacks may continue until they return. Candidate-consolidation timeout policy remains outside this PR's changes.
…luators Version bump 0.5.1 -> 0.5.2 and the changelog cut for everything merged since v0.5.1 (2026-08-29), 19 commits. In the wheel: the versioned EvalBench import pipeline (#451-#453: immutable snapshots with the W0.4 failed-session contract, the version-pinned failed_sessions view, evalbench-score), the native agent_events snapshot writer (#464), the failure taxonomy frozen at G1 v0.1.0 and its span-level localisation layer with persisted span labels (#467, #470), the evaluator API unification behind PerformanceEvaluator with compatibility aliases (#123), and the canonical metric factories plus opt-in policy scorecard (#91). Repo side: skill-evolution host hooks and auditable patch provenance (#395, #477), the scheduled skill-evolution Cloud Run Job (#472), the OKF adapter example (#474), the AgentForensics Week 0 freeze and sealed preregistration (#435, #473), and the EvalBench demo and CLI-discoverability follow-ups. Entries the Unreleased section lacked are added in this cut: #91, #123 (its bullets move under Changed with the PR number), #395, #472, #474, #476.
…478) * chore(release): 0.5.2 — EvalBench snapshots, G1 taxonomy, unified evaluators Version bump 0.5.1 -> 0.5.2 and the changelog cut for everything merged since v0.5.1 (2026-08-29), 19 commits. In the wheel: the versioned EvalBench import pipeline (#451-#453: immutable snapshots with the W0.4 failed-session contract, the version-pinned failed_sessions view, evalbench-score), the native agent_events snapshot writer (#464), the failure taxonomy frozen at G1 v0.1.0 and its span-level localisation layer with persisted span labels (#467, #470), the evaluator API unification behind PerformanceEvaluator with compatibility aliases (#123), and the canonical metric factories plus opt-in policy scorecard (#91). Repo side: skill-evolution host hooks and auditable patch provenance (#395, #477), the scheduled skill-evolution Cloud Run Job (#472), the OKF adapter example (#474), the AgentForensics Week 0 freeze and sealed preregistration (#435, #473), and the EvalBench demo and CLI-discoverability follow-ups. Entries the Unreleased section lacked are added in this cut: #91, #123 (its bullets move under Changed with the PR number), #395, #472, #474, #476. * chore(release): file the skill-evolution bullets as repo-side scripts/skill_evolution.py is not in the wheel (which ships only src/bigquery_agent_analytics and src/bigquery_ontology), so the #395 and #397 entries move from Added to their own repo-side section, matching the Deploy section this cut already uses for #472.
…pat scaffolding (#483) - New ANALYST_TIMEOUT_S env binding (default 600s, 0 = unbounded) passed through to the engine's analyst_timeout_s so a hung host analyst cannot stall the fleet past the Cloud Run task timeout. Malformed values fail the job at get_config(), matching the binding-env convention. - Remove supported_kwargs()/evolve_skill_compat feature detection and the secondary error_analyst gate: the baked engine always post-dates #395. - Remove deploy.sh --scripts-dir; the image always bakes scripts/ from the current checkout. - README: document ANALYST_TIMEOUT_S; stop describing the single-pass fallback as engine-dependent. Co-authored-by: Odysseus <evekhm+odyssey@gmail.com>
What
Three additive, backward-compatible seams in
scripts/skill_evolution.pyso a host system can adopt the engine as its single evolution implementation instead of maintaining a fork:error_analyst_fnoncollect_patches/evolve_skill— a host-supplied analyst for failure trajectories, called asfn(client, model, session, current_skill, tools)and returning patch text or None. Lets hosts plug in richer analysts (e.g. an agentic investigator with tool access, per Trace2Skill's finding that agentic analysis outperforms single-pass) while the fleet dispatch, quality gate, and consolidation stay in the engine. Success trajectories keep the built-in analyst.incumbent_scoreonselect_candidate/evolve_skill— hosts that already measured the base skill (the quality report the run consumes) pass the score instead of havingscore_fn(current_skill)re-measure the incumbent on fresh, noisy traffic.format_trajectory, rendered only when the session carries the keys (same backward-compatible pattern astool_calls_detail): verification counts,correction_boundaries(wrong claim vs corrected fact), per-segmentexecution_sub_trajectorieswith traces, and a full-sessionexecution_trace._has_parroted_recoveryalso honors a parroted outcome inexecution_sub_trajectories.All new parameters default to prior behavior; sessions without the new keys render exactly as before.
Testing
tests/test_skill_evolution.py; 41 total pass.evolve.pyis now a ~350-line adapter over this engine, replacing a 1,240-line fork; Evolution algorithm now lives in the SDK — evolve.py becomes a thin adapter evekhm/skill-evolution-lab#64 and fix: add ORDER BY before LIMIT for deterministic session ordering #56, both merged). Verified end-to-end on a fresh GCP project through theerror_analyst_fnhook (agentic analysts, 100% of patches through the quality gate), thetools=parameter (live-derived agent toolbox), and the incumbent guard consuming the pre-measured baseline: local pipeline V0 30.8–46.2% → V1 100.0% meaningful on the evolve set, 38.2% → 100.0% on a 55-question held-out exam; deployed pipeline V0 23.1% → V1 100.0%.