fix(learning-harness): say why the proposer had nothing, and stop hard-coding its vendor

The meta-agent round journalled `no_proposal`. The real cause was an
authentication failure: `_model_config` hard-coded the anthropic provider
while the detector was talking to DeepSeek and no ANTHROPIC_API_KEY exists
here. The composition itself was fine -- MetaAgent constructed, evolve
started, budget $5 -- and the trace was thrown away.

`no_proposal` covered two different worlds: a proposer that considered the
facts and declined, and one that could not start at all. The journal now
carries the proposer's own reason, and the vendor comes from
`[harnessx] provider` instead of being assumed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
Marius Ciepluch
2026-09-06 22:24:40 +02:00
co-authored by Claude Opus 5
parent 84b1ba7a72
commit cde57e4293
7 changed files with 66 additions and 12 deletions
+3 -3
View File
@@ -1369,7 +1369,7 @@ def _learning_run(args: argparse.Namespace, cases: Sequence[PairCase], taxonomy:
family=args.family,
taxonomy=taxonomy,
configs_dir=configs_dir,
proposer=_proposer(args, configs_dir, model),
proposer=_proposer(args, configs_dir, model, config.harnessx.provider),
scan_factory=factory,
model=model,
journal=journal,
@@ -1402,13 +1402,13 @@ def _label(case: PairCase, taxonomy: object) -> str:
return _family_of(case, taxonomy) # type: ignore[arg-type]
def _proposer(args: argparse.Namespace, configs_dir: Path, model: str) -> Proposer:
def _proposer(args: argparse.Namespace, configs_dir: Path, model: str, provider: str = "anthropic") -> Proposer:
"""`--proposals FILE` runs a recorded sequence offline; without it the round asks the meta-agent."""
from .learning.proposer import HarnessXProposer, Proposal, ScriptedProposer
path = getattr(args, "proposals", None)
if path is None:
return HarnessXProposer(configs_dir=configs_dir, model=model)
return HarnessXProposer(configs_dir=configs_dir, model=model, provider=provider)
rows = json.loads(Path(path).read_text(encoding="utf-8"))
return ScriptedProposer(
[
+6 -3
View File
@@ -177,6 +177,9 @@ class HarnessXProposer:
configs_dir: Path
model: str
# The vendor the meta-agent talks to. Hard-coding anthropic here sent it to a provider with no key configured
# while the detector was talking to DeepSeek, and the round journalled that as "no proposal".
provider: str = "anthropic"
reason: str = ""
agent: MetaAgentCall | None = None # injected in tests; None composes MetaAgent behind harness_ext
@@ -190,7 +193,7 @@ class HarnessXProposer:
self.reason = "harnessx_unavailable"
return None
try:
call = _meta_agent_call()
call = _meta_agent_call(self.provider)
except Exception: # noqa: BLE001 — a proposer that cannot start proposes nothing and says so
self.reason = "harnessx_unavailable"
return None
@@ -263,7 +266,7 @@ def _model_config(model: str, provider: str = "anthropic") -> object:
return ModelConfig(main=build_provider(model, provider))
def _meta_agent_call() -> MetaAgentCall:
def _meta_agent_call(provider: str = "anthropic") -> MetaAgentCall:
"""Compose `MetaAgent.evolve` behind `harness_ext`, writing only inside the scratch copy.
`evolve` is a coroutine and returns a HarnessX config path; what this proposer takes from it is the state of the
@@ -279,7 +282,7 @@ def _meta_agent_call() -> MetaAgentCall:
trajectories = workspace.parent / "trajectories"
trajectories.mkdir(parents=True, exist_ok=True)
(trajectories / "facts.json").write_text(facts_prompt(facts), encoding="utf-8")
agent = _meta_agent_class()(inner_model=_model_config(model), allowed_write_roots=(workspace,))
agent = _meta_agent_class()(inner_model=_model_config(model, provider), allowed_write_roots=(workspace,))
output = workspace.parent / "meta-out"
output.mkdir(parents=True, exist_ok=True)
asyncio.run(agent.evolve(current_config=workspace, trajectories_dir=trajectories, output_dir=output))
+12 -1
View File
@@ -418,7 +418,18 @@ def run_learning_round(
proposal = proposer.propose(facts) # type: ignore[attr-defined]
record = _round_record(round_number, family, proposal, taxonomy, config)
if proposal is None:
return _finish(journal, directory, record, outcome="rejected", reason="no_proposal", cost=spent, trajectories=trajectories)
# `no_proposal` covers two different worlds: a proposer that considered the facts and declined, and one
# that could not start at all. Losing that distinction hides a fixable configuration error as a quiet round.
why = str(getattr(proposer, "reason", "") or "")
return _finish(
journal,
directory,
record,
outcome="rejected",
reason=f"no_proposal: {why}" if why else "no_proposal",
cost=spent,
trajectories=trajectories,
)
refusal = refuse_proposal(proposal, config)
if refusal is not None:
return _finish(