diff --git a/README.md b/README.md index 4ba1262..fc66157 100644 --- a/README.md +++ b/README.md @@ -76,21 +76,60 @@ With the default `launcher="resident"`, `h5i` automatically starts the agent ses ## 3. Examples -For example, you can program: - -- ask Claude and Codex to implement the same task independently, have them review and improve each other's work, and select the smallest candidate that passes the tests; -- let Claude Fable and Codex GPT-5.6 Sol iteratively refine a design, then hand the agreed design to Claude Opus for implementation; or -- repeat a Fable-design/Sol-review loop ten times, ask Opus to implement the result, and invoke Sol to repair the implementation only when Fable rejects it. - -See [examples/](./examples/) for complete scores, including: - -- [arena_score.py](./examples/arena_score.py): independent arena ranking; -- [ensemble_score.py](./examples/ensemble_score.py): mutual-review ensembles; -- [debate_then_build.py](./examples/debate_then_build.py): architect-to-implementer pipelines; -- [review_escalation.py](./examples/review_escalation.py): conditional review escalation; -- [judge_panel_score.py](./examples/judge_panel_score.py): LLM judge panels; -- [tournament.py](./examples/tournament.py): tournament brackets; and -- [custom_control_flow.py](./examples/custom_control_flow.py): custom Python control flow. +[examples/tutorial](./examples/tutorial/) provides basic multi-agent orchestration patterns: + +- [arena_score.py](./examples/tutorial/arena_score.py): independent arena ranking; +- [ensemble_score.py](./examples/tutorial/ensemble_score.py): mutual-review ensembles; +- [debate_then_build.py](./examples/tutorial/debate_then_build.py): architect-to-implementer pipelines; +- [review_escalation.py](./examples/tutorial/review_escalation.py): conditional review escalation; +- [judge_panel_score.py](./examples/tutorial/judge_panel_score.py): LLM judge panels; +- [tournament.py](./examples/tutorial/tournament.py): tournament brackets; and +- [custom_control_flow.py](./examples/tutorial/custom_control_flow.py): custom Python control flow. + +[examples/papers/](./examples/papers/) re-implements the core workflow of 40 published multi-agent papers: + +| Paper | Example | Summary | +|---|---|---| +| [Self-Refine](https://arxiv.org/abs/2303.17651) | [self_refine.py](./examples/papers/self_refine.py) | Generate, self-critique, refine until the critic approves. | +| [Reflexion](https://arxiv.org/abs/2303.11366) | [reflexion.py](./examples/papers/reflexion.py) | Verbal reflections on test failures accumulate as episodic memory across retries. | +| [CRITIC](https://arxiv.org/abs/2305.11738) | [critic.py](./examples/papers/critic.py) | Critiques grounded in external tool runs drive each correction. | +| [Self-Debug](https://arxiv.org/abs/2304.05128) | [self_debugging.py](./examples/papers/self_debugging.py) | Explain your own code line by line (rubber duck), then fix. | +| [Constitutional AI](https://arxiv.org/abs/2212.08073) | [constitutional_ai.py](./examples/papers/constitutional_ai.py) | Per-principle critiques against a written constitution fold into each revision. | +| [Self-Consistency](https://arxiv.org/abs/2203.11171) | [self_consistency.py](./examples/papers/self_consistency.py) | N independent reasoning paths, majority vote over the final answers. | +| [More Agents Is All You Need](https://arxiv.org/abs/2402.05120) | [agent_forest.py](./examples/papers/agent_forest.py) | Sampling-and-voting, with a scaling curve over ensemble size. | +| [Universal Self-Consistency](https://arxiv.org/abs/2311.17311) | [universal_self_consistency.py](./examples/papers/universal_self_consistency.py) | A selector picks the free-form response most consistent with the sample population. | +| [Multiagent Debate](https://arxiv.org/abs/2305.14325) | [multiagent_debate.py](./examples/papers/multiagent_debate.py) | Answer independently, read the others, update; majority vote at the end. | +| [MAD: Divergent Thinking](https://arxiv.org/abs/2305.19118) | [mad_divergent.py](./examples/papers/mad_divergent.py) | An obligated-to-disagree negative side debates the affirmative under an adaptive judge. | +| [ReConcile](https://arxiv.org/abs/2309.13007) | [reconcile.py](./examples/papers/reconcile.py) | A model-diverse round table converges by confidence-weighted vote. | +| [Persuasive Debate](https://arxiv.org/abs/2402.06782) | [persuasive_debate.py](./examples/papers/persuasive_debate.py) | Debaters argue assigned sides; a transcript-only judge decides. | +| [Negotiation Self-Play](https://arxiv.org/abs/2305.10142) | [negotiation.py](./examples/papers/negotiation.py) | Buyer/seller bargaining games improve via a critic's in-context feedback. | +| [ChatEval](https://arxiv.org/abs/2308.07201) | [chateval.py](./examples/papers/chateval.py) | Persona-diverse judges debate one-by-one before scoring the candidates. | +| [Multi-Agent Verification](https://arxiv.org/abs/2502.20379) | [mav_bon.py](./examples/papers/mav_bon.py) | Best-of-n candidates times m binary aspect verifiers; most approvals wins. | +| [Chain-of-Verification](https://arxiv.org/abs/2309.11495) | [chain_of_verification.py](./examples/papers/chain_of_verification.py) | Draft, verify with questions answered by seats that never saw the draft, revise. | +| [SelfCheckGPT](https://arxiv.org/abs/2303.08896) | [selfcheckgpt.py](./examples/papers/selfcheckgpt.py) | Per-sentence consistency against independent samples flags hallucinations. | +| [PRD: Peer Rank & Discussion](https://arxiv.org/abs/2307.02762) | [prd_peer_rank.py](./examples/papers/prd_peer_rank.py) | Contestants judge all answer pairs; agreement-weighted ranking plus discussion. | +| [LLM-Blender](https://arxiv.org/abs/2306.02561) | [llm_blender.py](./examples/papers/llm_blender.py) | Pairwise-rank candidates in both orders, then fuse the top-k. | +| [Mixture-of-Agents](https://arxiv.org/abs/2406.04692) | [mixture_of_agents.py](./examples/papers/mixture_of_agents.py) | Layered proposers each fed the whole previous layer; an aggregator synthesizes. | +| [Tree of Thoughts](https://arxiv.org/abs/2305.10601) | [tree_of_thoughts.py](./examples/papers/tree_of_thoughts.py) | Beam search over partial plans; only the best leaf pays for real work. | +| [Graph of Thoughts](https://arxiv.org/abs/2308.09687) | [graph_of_thoughts.py](./examples/papers/graph_of_thoughts.py) | Generate/score/aggregate/refine thought transformations on an explicit DAG. | +| [LATS](https://arxiv.org/abs/2310.04406) | [lats.py](./examples/papers/lats.py) | MCTS over real attempts, with test results as reward and reflections on failures. | +| [Least-to-Most](https://arxiv.org/abs/2205.10625) | [least_to_most.py](./examples/papers/least_to_most.py) | Decompose easiest-first; solve in order with every prior answer in context. | +| [Skeleton-of-Thought](https://arxiv.org/abs/2307.15337) | [skeleton_of_thought.py](./examples/papers/skeleton_of_thought.py) | Outline first, expand every point in parallel, assemble in order. | +| [Meta-Prompting](https://arxiv.org/abs/2401.12954) | [meta_prompting.py](./examples/papers/meta_prompting.py) | A conductor invents expert personas on the fly and consults fresh seats. | +| [Chain of Agents](https://arxiv.org/abs/2406.02818) | [chain_of_agents.py](./examples/papers/chain_of_agents.py) | Sequential workers pass a communication unit across chunks; a manager answers. | +| [STORM](https://arxiv.org/abs/2402.14207) | [storm.py](./examples/papers/storm.py) | Perspectives, simulated writer-expert interviews, outline, then the article. | +| [CAMEL](https://arxiv.org/abs/2303.17760) | [camel.py](./examples/papers/camel.py) | Inception-prompted user/assistant role play, one instruction at a time. | +| [AgentCoder](https://arxiv.org/abs/2312.13010) | [agentcoder.py](./examples/papers/agentcoder.py) | Programmer and mutually blind test designer; a neutral executor loops failures back. | +| [MapCoder](https://arxiv.org/abs/2405.11403) | [mapcoder.py](./examples/papers/mapcoder.py) | Exemplar recall, confidence-ranked plans, and plan-wise bounded debugging. | +| [MetaGPT](https://arxiv.org/abs/2308.00352) | [metagpt.py](./examples/papers/metagpt.py) | Roles exchange structured documents (PRD, design, QA report), never free chat. | +| [ChatDev](https://arxiv.org/abs/2307.07924) | [chatdev.py](./examples/papers/chatdev.py) | A chat chain: every waterfall phase is a two-role dialogue with a settled deliverable. | +| [CodeT](https://arxiv.org/abs/2207.10397) | [codet.py](./examples/papers/codet.py) | Rank blind solutions by agreement with an independently generated test suite. | +| [AlphaCodium](https://arxiv.org/abs/2401.08500) | [alphacodium.py](./examples/papers/alphacodium.py) | Problem reflection and AI-generated tests before coding; iterate until all green. | +| [Agentless](https://arxiv.org/abs/2407.01489) | [agentless.py](./examples/papers/agentless.py) | A fixed localize, repair, validate pipeline — no agentic wandering. | +| [Parsel](https://arxiv.org/abs/2212.10561) | [parsel.py](./examples/papers/parsel.py) | Decompose into a function graph; implement parts in parallel, compose, test. | +| [Exchange-of-Thought](https://arxiv.org/abs/2312.01823) | [exchange_of_thought.py](./examples/papers/exchange_of_thought.py) | Four communication topologies (bus/star/ring/tree) as a who-sees-what function. | +| [DyLAN](https://arxiv.org/abs/2310.02170) | [dylan.py](./examples/papers/dylan.py) | Rank each round's contributions and deactivate the weakest seat as you go. | +| [AgentVerse](https://arxiv.org/abs/2308.10848) | [agentverse.py](./examples/papers/agentverse.py) | Recruit, collaborate, evaluate — and re-recruit a better team with fresh mid-run hires. | ## 4. License diff --git a/examples/README.md b/examples/README.md index cd4a430..fb5fb51 100644 --- a/examples/README.md +++ b/examples/README.md @@ -48,14 +48,14 @@ as an optional CLI argument and falls back to the same demo task, **`implement quicksort with pytest`** — so a bare invocation always works: ```bash -python examples/ensemble_score.py # demo task -python examples/arena_score.py "implement quicksort with pytest" -python examples/review_escalation.py "fix the flaky msg_integration test" -python examples/pipeline_score.py -python examples/judge_panel_score.py -python examples/debate_then_build.py -python examples/tournament.py -python examples/custom_control_flow.py # uses the default "attach" launcher: +python examples/tutorial/ensemble_score.py # demo task +python examples/tutorial/arena_score.py "implement quicksort with pytest" +python examples/tutorial/review_escalation.py "fix the flaky msg_integration test" +python examples/tutorial/pipeline_score.py +python examples/tutorial/judge_panel_score.py +python examples/tutorial/debate_then_build.py +python examples/tutorial/tournament.py +python examples/tutorial/custom_control_flow.py # uses the default "attach" launcher: # park resident sessions yourself first ``` @@ -82,20 +82,28 @@ in an `apply` pause at a durable human gate: the question is delivered over | Example | Pattern | When to reach for it | |---|---|---| -| [`ensemble_score.py`](ensemble_score.py) | `ensemble` | Consensus: independent attempts, mutual review/revise, verify, verdict, gated apply. | -| [`arena_score.py`](arena_score.py) | `arena` | Competition: best of N independent tries, ranked by neutral verification + smallest diff. | -| [`pipeline_score.py`](pipeline_score.py) | `pipeline` | Assembly line: architect → implementer → hardener, each stage fed the last stage's artifact. | -| [`judge_panel_score.py`](judge_panel_score.py) | `judge_panel` | Judgment beyond tests: LLM judges score sealed candidates against a rubric, citing recorded evidence. | -| [`debate_then_build.py`](debate_then_build.py) | `debate` | Decide before building: argue a design question, then let the conclusion steer real work turns. | +| [`ensemble_score.py`](tutorial/ensemble_score.py) | `ensemble` | Consensus: independent attempts, mutual review/revise, verify, verdict, gated apply. | +| [`arena_score.py`](tutorial/arena_score.py) | `arena` | Competition: best of N independent tries, ranked by neutral verification + smallest diff. | +| [`pipeline_score.py`](tutorial/pipeline_score.py) | `pipeline` | Assembly line: architect → implementer → hardener, each stage fed the last stage's artifact. | +| [`judge_panel_score.py`](tutorial/judge_panel_score.py) | `judge_panel` | Judgment beyond tests: LLM judges score sealed candidates against a rubric, citing recorded evidence. | +| [`debate_then_build.py`](tutorial/debate_then_build.py) | `debate` | Decide before building: argue a design question, then let the conclusion steer real work turns. | ## Composed control flow (the define-by-run payoff) | Example | Shows | |---|---| -| [`custom_control_flow.py`](custom_control_flow.py) | `ask` data turns feeding `if`, journaled `step`/`scope` effects, dynamic `map_reduce` fan-out (`integrate` under the hood), `patched` mid-run migration, a custom verdict policy as a plain function. | -| [`review_escalation.py`](review_escalation.py) | An escalation ladder: cheap model first, senior review loop, senior takeover with the junior's artifact as material. Three workflow-node-types' worth of logic in one `for` and one `if`. | -| [`tournament.py`](tournament.py) | Multi-run orchestration: a bracket of arena matches, semifinals in parallel — Conductors are just objects, so composing *runs* is composing function calls. | +| [`custom_control_flow.py`](tutorial/custom_control_flow.py) | `ask` data turns feeding `if`, journaled `step`/`scope` effects, dynamic `map_reduce` fan-out (`integrate` under the hood), `patched` mid-run migration, a custom verdict policy as a plain function. | +| [`review_escalation.py`](tutorial/review_escalation.py) | An escalation ladder: cheap model first, senior review loop, senior takeover with the junior's artifact as material. Three workflow-node-types' worth of logic in one `for` and one `if`. | +| [`tournament.py`](tutorial/tournament.py) | Multi-run orchestration: a bracket of arena matches, semifinals in parallel — Conductors are just objects, so composing *runs* is composing function calls. | None of the pattern functions are privileged — each is ~40 lines of the same public SDK these examples use (`src/h5i/orchestra/patterns.py`). When a pattern almost fits, copy it into your score and edit it. + +## Paper scores + +[`papers/`](papers/README.md) holds reference implementations of forty +published multi-agent workflows — Self-Refine, Reflexion, Tree of Thoughts, +Mixture-of-Agents, MetaGPT, ChatDev, LATS, AgentVerse, and more — each +paper's core loop expressed as an ordinary score over the same public SDK. Same prerequisites +as above; see that README for the map from paper to primitives. diff --git a/examples/papers/README.md b/examples/papers/README.md new file mode 100644 index 0000000..c430b59 --- /dev/null +++ b/examples/papers/README.md @@ -0,0 +1,117 @@ +# Paper scores + +Reference implementations of forty published multi-agent workflows, each +expressed as an ordinary h5i score. The goal is the *core algorithm* of each +paper — the loop, the roles, the aggregation rule — not a reproduction of its +experiments: every score is generic over the task it is given (pass your own +as the CLI argument), and every effectful turn is journaled, so any score can +be killed and re-run to resume where it stopped. + +Prerequisites, model pinning, and runtime setup are the same as the parent +[examples/README.md](../README.md). Scores that produce code artifacts default +to the shared demo task (`implement quicksort with pytest`) and verify with +`pytest -q`; scores over questions or instructions are pure `ask` data turns +and need no clean worktree. + +## Refine loops (one worker, an external signal) + +| Score | Paper | The loop on h5i | +|---|---|---| +| [`self_refine.py`](self_refine.py) | Self-Refine ([arXiv:2303.17651](https://arxiv.org/abs/2303.17651)) | generate → critic seat (same model) reviews → revise, until approved. | +| [`reflexion.py`](reflexion.py) | Reflexion ([arXiv:2303.11366](https://arxiv.org/abs/2303.11366)) | `verify` as the evaluator; verbal reflections accumulate in an episodic buffer delivered as the revise review. | +| [`critic.py`](critic.py) | CRITIC ([arXiv:2305.11738](https://arxiv.org/abs/2305.11738)) | a toolbox of `verify` commands grounds each critique; correct → re-verify. | +| [`self_debugging.py`](self_debugging.py) | Self-Debug ([arXiv:2304.05128](https://arxiv.org/abs/2304.05128)) | rubber-duck: explain your own code line by line before revising against the failure. | +| [`constitutional_ai.py`](constitutional_ai.py) | Constitutional AI ([arXiv:2212.08073](https://arxiv.org/abs/2212.08073)) | per-principle critiques of a draft against an explicit constitution, folded into each revision. | + +## Sampling and voting + +| Score | Paper | The loop on h5i | +|---|---|---| +| [`self_consistency.py`](self_consistency.py) | Self-Consistency ([arXiv:2203.11171](https://arxiv.org/abs/2203.11171)) | N independent seats reason in parallel; majority vote marginalizes the chains. | +| [`agent_forest.py`](agent_forest.py) | More Agents Is All You Need ([arXiv:2402.05120](https://arxiv.org/abs/2402.05120)) | sampling-and-voting, with the scaling curve re-voted over prefixes of one sample set. | +| [`universal_self_consistency.py`](universal_self_consistency.py) | Universal Self-Consistency ([arXiv:2311.17311](https://arxiv.org/abs/2311.17311)) | free-form voting: a selector `ask` picks the sample most consistent with the population. | + +## Debate and consensus + +| Score | Paper | The loop on h5i | +|---|---|---| +| [`multiagent_debate.py`](multiagent_debate.py) | Multiagent Debate ([arXiv:2305.14325](https://arxiv.org/abs/2305.14325)) | answer independently → read the others → update, for R rounds; majority at the end. | +| [`mad_divergent.py`](mad_divergent.py) | MAD ([arXiv:2305.19118](https://arxiv.org/abs/2305.19118)) | an obligated-to-disagree negative side vs. affirmative, judged adaptively each round. | +| [`reconcile.py`](reconcile.py) | ReConcile ([arXiv:2309.13007](https://arxiv.org/abs/2309.13007)) | model-diverse round table; explanations shared each round; confidence-weighted vote. | +| [`persuasive_debate.py`](persuasive_debate.py) | Persuasive Debate ([arXiv:2402.06782](https://arxiv.org/abs/2402.06782)) | debaters argue *assigned* sides; a transcript-only judge decides — naive vs. informed judgment in one run. | +| [`negotiation.py`](negotiation.py) | Negotiation Self-Play ([arXiv:2305.10142](https://arxiv.org/abs/2305.10142)) | buyer/seller bargaining games; a critic's notes carry into the next game as in-context learning. | + +## Verification and judging of sealed candidates + +| Score | Paper | The loop on h5i | +|---|---|---| +| [`chateval.py`](chateval.py) | ChatEval ([arXiv:2308.07201](https://arxiv.org/abs/2308.07201)) | persona judges score one-by-one with the running transcript threaded through; `mean_score_verdict` over the debated ballots. | +| [`mav_bon.py`](mav_bon.py) | Multi-Agent Verification ([arXiv:2502.20379](https://arxiv.org/abs/2502.20379)) | best-of-n candidates × m binary aspect verifiers over recorded evidence; most approvals wins as a verdict policy. | +| [`chain_of_verification.py`](chain_of_verification.py) | CoVe ([arXiv:2309.11495](https://arxiv.org/abs/2309.11495)) | draft → verification questions answered by seats that never saw the draft (factored) → revise. | +| [`selfcheckgpt.py`](selfcheckgpt.py) | SelfCheckGPT ([arXiv:2303.08896](https://arxiv.org/abs/2303.08896)) | per-sentence support of a primary answer checked against N independent samples; low-support flagged. | +| [`prd_peer_rank.py`](prd_peer_rank.py) | PRD ([arXiv:2307.02762](https://arxiv.org/abs/2307.02762)) | contestants judge every answer pair both ways; reviewer weight = agreement with the aggregate; discussion settles the top pair. | + +## Ensembling generations + +| Score | Paper | The loop on h5i | +|---|---|---| +| [`llm_blender.py`](llm_blender.py) | LLM-Blender ([arXiv:2306.02561](https://arxiv.org/abs/2306.02561)) | pairwise ranking (both presentation orders, position-bias-proof) → generative fusion of the top-k. | +| [`mixture_of_agents.py`](mixture_of_agents.py) | Mixture-of-Agents ([arXiv:2406.04692](https://arxiv.org/abs/2406.04692)) | layered proposers, each fed the whole previous layer; a final aggregator synthesizes. | + +## Search, long context, and writing + +| Score | Paper | The loop on h5i | +|---|---|---| +| [`tree_of_thoughts.py`](tree_of_thoughts.py) | Tree of Thoughts ([arXiv:2305.10601](https://arxiv.org/abs/2305.10601)) | beam-searched plan tree on cheap `ask` turns; only the best leaf pays for a work turn. | +| [`graph_of_thoughts.py`](graph_of_thoughts.py) | Graph of Thoughts ([arXiv:2308.09687](https://arxiv.org/abs/2308.09687)) | generate/score/keep-best plus *aggregate* and *refine* on an explicit DAG the score prints. | +| [`lats.py`](lats.py) | LATS ([arXiv:2310.04406](https://arxiv.org/abs/2310.04406)) | MCTS where expansion is a revise turn, reward blends `verify` with an LLM value, and failures leave reflections. | +| [`least_to_most.py`](least_to_most.py) | Least-to-Most ([arXiv:2205.10625](https://arxiv.org/abs/2205.10625)) | decompose easiest-first, then solve in order with every prior Q/A in the prompt. | +| [`skeleton_of_thought.py`](skeleton_of_thought.py) | Skeleton-of-Thought ([arXiv:2307.15337](https://arxiv.org/abs/2307.15337)) | skeleton `ask`, then all points expanded in one cross-seat `gather` and stitched in order. | +| [`chain_of_agents.py`](chain_of_agents.py) | Chain of Agents ([arXiv:2406.02818](https://arxiv.org/abs/2406.02818)) | sequential workers rewrite a communication unit chunk by chunk; the manager answers from the final unit alone. | +| [`storm.py`](storm.py) | STORM ([arXiv:2402.14207](https://arxiv.org/abs/2402.14207)) | perspectives → simulated writer↔expert interviews → outline → article. | +| [`camel.py`](camel.py) | CAMEL ([arXiv:2303.17760](https://arxiv.org/abs/2303.17760)) | task specifier, then inception-prompted user/assistant role-play, one instruction at a time. | + +## Software-engineering SOPs + +| Score | Paper | The loop on h5i | +|---|---|---| +| [`agentcoder.py`](agentcoder.py) | AgentCoder ([arXiv:2312.13010](https://arxiv.org/abs/2312.13010)) | programmer ∥ mutually-blind test designer; suite granted as materials; neutral executor loops failures back. | +| [`mapcoder.py`](mapcoder.py) | MapCoder ([arXiv:2405.11403](https://arxiv.org/abs/2405.11403)) | self-generated exemplars → confidence-ranked plans → code → bounded plan-wise debugging, switching plans when exhausted. | +| [`metagpt.py`](metagpt.py) | MetaGPT ([arXiv:2308.00352](https://arxiv.org/abs/2308.00352)) | roles exchange validated JSON documents (PRD, design, QA report), never free chat. | +| [`chatdev.py`](chatdev.py) | ChatDev ([arXiv:2307.07924](https://arxiv.org/abs/2307.07924)) | a chat chain: every waterfall phase is a two-role dialogue with a settled deliverable. | +| [`codet.py`](codet.py) | CodeT ([arXiv:2207.10397](https://arxiv.org/abs/2207.10397)) | n blind solutions × an independent test suite; rank by dual execution agreement as a verdict policy. | +| [`alphacodium.py`](alphacodium.py) | AlphaCodium ([arXiv:2401.08500](https://arxiv.org/abs/2401.08500)) | flow engineering: structured problem reflection → AI-generated extra tests → code → iterate until all green. | +| [`agentless.py`](agentless.py) | Agentless ([arXiv:2407.01489](https://arxiv.org/abs/2407.01489)) | fixed pipeline: hierarchical localization over a journaled repo tree → minimal repair → validation. | +| [`parsel.py`](parsel.py) | Parsel ([arXiv:2212.10561](https://arxiv.org/abs/2212.10561)) | decompose into a function graph, implement each spec via `map_reduce`, compose and test the whole. | + +## Team topology & self-organization + +| Score | Paper | The loop on h5i | +|---|---|---| +| [`exchange_of_thought.py`](exchange_of_thought.py) | Exchange-of-Thought ([arXiv:2312.01823](https://arxiv.org/abs/2312.01823)) | Memory/Report/Relay/Debate topologies as a who-sees-what function; confidence-based termination. | +| [`dylan.py`](dylan.py) | DyLAN ([arXiv:2310.02170](https://arxiv.org/abs/2310.02170)) | a ranker scores contributions each round and the weakest seat is deactivated — the roster is mutable Python state. | +| [`agentverse.py`](agentverse.py) | AgentVerse ([arXiv:2308.10848](https://arxiv.org/abs/2308.10848)) | recruit → collaborate → evaluate → re-recruit, hiring genuinely fresh seats mid-run (a pure-`ask` score never freezes). | +| [`meta_prompting.py`](meta_prompting.py) | Meta-Prompting ([arXiv:2401.12954](https://arxiv.org/abs/2401.12954)) | a conductor invents expert personas on the fly and consults clean seats that see only its instructions. | + +## Reading order + +The scores share scaffolding and build on each other: + +1. `self_refine` → `reflexion` → `critic` — one worker, three feedback sources + (a critic seat, its own reflections, external tools). +2. `self_consistency` → `agent_forest` → `multiagent_debate` — from voting to + debating, same parallel-`ask` skeleton. +3. `chateval` and `mav_bon` — two ways to judge sealed candidates beyond + what `patterns.judge_panel` ships. +4. `agentcoder` → `mapcoder` → `metagpt` → `chatdev` — increasingly structured + software SOPs over the same `work`/`verify`/`revise` turns. +5. Batch-2 siblings extend each family: `self_debugging`/`constitutional_ai` + add new feedback sources to the refine loops; `universal_self_consistency` + frees the vote from exact matching; `chain_of_verification`/`selfcheckgpt` + turn sampling into fact-checking; `graph_of_thoughts` → `lats` deepen the + search family; `codet`/`alphacodium`/`agentless`/`parsel` complete the + codegen tier; and the topology group (`exchange_of_thought`, `dylan`, + `agentverse`, `meta_prompting`) makes team structure itself the variable. + +None of these scores use privileged API — where one is close to what you +need, copy it and edit (see the parent README's note on forking patterns). diff --git a/examples/papers/agent_forest.py b/examples/papers/agent_forest.py new file mode 100644 index 0000000..6aab16f --- /dev/null +++ b/examples/papers/agent_forest.py @@ -0,0 +1,75 @@ +"""More Agents Is All You Need (Li et al. 2024, arXiv:2402.05120) — +sampling-and-voting, with its scaling curve. + +The paper's finding: plain sampling-and-voting — spawn N agents on the same +input, take the majority — scales performance with ensemble size, no +scaffolding needed, and stacks on top of any other method. Here N seats +answer in parallel and the vote is host-side Python; the scaling flavor +comes free by re-voting over prefixes of the same samples (1, 3, …, N) +instead of paying for separate runs. Exact-match voting suits closed-form +answers; the paper uses pairwise similarity for open-ended ones — swap +``canon`` accordingly. + + python examples/papers/agent_forest.py [""] +""" + +import asyncio +import sys +from collections import Counter +from typing import Any, Mapping + +from h5i.orchestra import Conductor + +DEMO_QUESTION = ( + "A farmer has a fox, a goose, and a bag of grain, and a boat that " + "carries the farmer plus one item. The fox eats the goose, and the " + "goose eats the grain, when left alone together. What is the minimum " + "number of river crossings to get everything across?" +) +N_AGENTS = 5 + + +def parse_answer(value: Any) -> str: + if not isinstance(value, Mapping) or "answer" not in value: + raise ValueError('reply must be {"reasoning": "...", "answer": "..."}') + return str(value["answer"]).strip() + + +def canon(answer: str) -> str: + return answer.strip().lower() + + +def majority(samples: list[str]) -> tuple[str, int]: + winner, n = Counter(canon(s) for s in samples).most_common(1)[0] + return winner, n + + +async def main(question: str) -> None: + prompt = ( + f"{question}\n\nReason step by step, then reply as JSON: " + '{"reasoning": "", ' + '"answer": ""}' + ) + async with Conductor(".", "agent-forest-demo", launcher="resident", isolation="supervised") as c: + forest = [ + await c.hire(f"tree{i}", runtime="claude", model="claude-haiku-4-5") + for i in range(N_AGENTS) + ] + + # The whole method: N independent samples, then vote. + samples = list( + await asyncio.gather(*(seat.ask(prompt, parse=parse_answer) for seat in forest)) + ) + + # The scaling curve, from prefixes of the same sample set. + for n in range(1, N_AGENTS + 1, 2): + winner, votes = majority(samples[:n]) + print(f"ensemble size {n}: '{winner}' ({votes}/{n} votes)") + + winner, votes = majority(samples) + await c.note(f"agent-forest: '{winner}' won {votes}/{N_AGENTS} votes") + print(f"\nfinal answer: {winner}") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_QUESTION)) diff --git a/examples/papers/agentcoder.py b/examples/papers/agentcoder.py new file mode 100644 index 0000000..669a42c --- /dev/null +++ b/examples/papers/agentcoder.py @@ -0,0 +1,96 @@ +"""AgentCoder: Multi-Agent-based Code Generation with Effective Testing and +Optimisation (Huang et al. 2024, arXiv:2312.13010) — programmer / test +designer / test executor. + +The test designer writes tests *without seeing the implementation* — tests +written after the code inherit its blind spots. So both seats work in +parallel before the freeze (h5i stamps both artifacts independent), then +the programmer applies the granted test suite as sealed-phase materials and +must satisfy it. The test executor role is not an LLM at all: +``conductor.verify`` runs the suite neutrally, and each failure loops back +to the programmer as a constructed review. + + python examples/papers/agentcoder.py [""] # default: implement quicksort with pytest +""" + +import asyncio +import sys + +from h5i.orchestra import Conductor, Review, Verification + +DEMO_TASK = "implement quicksort with pytest" +VERIFY = ["pytest", "-q"] +MAX_ITERATIONS = 3 + + +def describe(v: Verification) -> str: + line = ( + f"`{' '.join(v.command)}`: " + + ("applied cleanly" if v.applies_cleanly else "failed to apply") + + ", " + + ("tests passed" if v.tests_passed else "tests FAILED") + ) + if v.failure: + line += f"\nfailure: {v.failure}" + return line + + +async def main(task: str) -> None: + async with Conductor(".", "agentcoder-demo", launcher="resident", isolation="supervised") as c: + programmer = await c.hire("programmer", runtime="claude", model="claude-haiku-4-5") + test_designer = await c.hire( + "test-designer", runtime="codex", model="gpt-5.4-mini", effort="medium" + ) + + # Implementation and test suite in parallel, mutually blind. + implementation, tests = await asyncio.gather( + programmer.work(task, expect_independent=True), + test_designer.work( + f"Design the test suite ONLY for: {task}\nWrite thorough " + "tests (basic, edge, and large-scale cases). Do NOT write " + "the implementation itself.", + expect_independent=True, + ), + ) + await c.freeze() + + # The programmer takes on the independent test suite as materials. + candidate = await programmer.work( + "Apply the granted teammate artifact — an independently designed " + "test suite for your task — into your worktree alongside your " + "implementation. Do not weaken or delete tests; adjust your " + "implementation until it honestly satisfies them.", + materials=[tests], + ) + + # Test executor loop: neutral runs, failures loop back as reviews. + for iteration in range(1, MAX_ITERATIONS + 1): + verification = await c.verify(candidate, VERIFY) + if verification.applies_cleanly and verification.tests_passed: + print(f"iteration {iteration}: test executor is green") + break + evidence = describe(verification) + print(f"iteration {iteration}: tests failed") + if iteration == MAX_ITERATIONS: + break + candidate = await programmer.revise( + candidate, + Review( + reviewer="test-executor", + target=programmer.id, + round=candidate.round, + body=( + "Verdict: REVISE\n\nThe independently designed test " + f"suite failed in a neutral worktree:\n{evidence}\n\n" + "Fix the implementation; do not touch the tests." + ), + referenced_artifacts=(candidate.id,), + ), + ) + + verdict = await c.judge() + print("verdict:", verdict.selected_submission or "none", "—", *verdict.reasons) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/agentless.py b/examples/papers/agentless.py new file mode 100644 index 0000000..fdd73ca --- /dev/null +++ b/examples/papers/agentless.py @@ -0,0 +1,113 @@ +"""Agentless: Demystifying LLM-based Software Engineering Agents (Xia et +al. 2024, arXiv:2407.01489) — three fixed phases, no agentic wandering. + +Agentless argues that for repository work a *fixed* pipeline beats an +autonomous agent deciding its own next action: hierarchical localization +(repository structure → files → specific elements), minimal repair, then +validation. Here the file tree and file excerpts are journaled ``step`` +effects (a resume replays the exact same view of the repo), localization +is two validated-JSON ``ask`` turns, repair is one work turn constrained +to the located files, and validation is neutral verification. + + python examples/papers/agentless.py [""] + # default issue: implement quicksort with pytest +""" + +import asyncio +import sys +from pathlib import Path +from typing import Any + +from h5i.orchestra import Conductor + +DEMO_ISSUE = "implement quicksort with pytest" +VERIFY = ["pytest", "-q"] +MAX_FILES = 3 +TREE_LIMIT = 200 # files listed to the locator +EXCERPT_CHARS = 2_000 # per-file excerpt shown for element localization + +SKIP_PARTS = {".git", ".venv", "node_modules", "__pycache__", "target"} + + +def repo_tree() -> list[str]: + files = [ + str(p) + for p in sorted(Path(".").rglob("*")) + if p.is_file() and not (set(p.parts) & SKIP_PARTS) + ] + return files[:TREE_LIMIT] + + +def excerpts(paths: list[str]) -> str: + parts = [] + for path in paths: + try: + text = Path(path).read_text(encoding="utf-8", errors="replace") + except OSError as e: + text = f"(unreadable: {e})" + parts.append(f"--- {path} ---\n{text[:EXCERPT_CHARS]}") + return "\n\n".join(parts) + + +def parse_files(known: list[str]): + def parse(value: Any) -> list[str]: + files = value.get("files") if isinstance(value, dict) else value + if not isinstance(files, list) or not files: + raise ValueError('reply must be {"files": ["", ...]}') + chosen = [str(f).strip() for f in files][:MAX_FILES] + unknown = [f for f in chosen if f not in known] + if unknown: + raise ValueError(f"paths not in the repository tree: {unknown}") + return chosen + + return parse + + +def parse_text(value: Any) -> str: + return value if isinstance(value, str) else str(value) + + +async def main(issue: str) -> None: + async with Conductor(".", "agentless-demo", launcher="resident", isolation="supervised") as c: + locator = await c.hire("locator", runtime="claude", model="claude-haiku-4-5") + fixer = await c.hire("fixer", runtime="claude", model="claude-haiku-4-5") + + # Phase 1a — file-level localization over the journaled repo tree. + tree = await c.step("repo-tree", repo_tree) + files = await locator.ask( + f"Issue: {issue}\n\nRepository files:\n" + "\n".join(tree) + "\n\n" + f"Which files (at most {MAX_FILES}) must change to resolve the " + "issue? If the change belongs in a NEW file, pick the closest " + 'existing neighbors instead. Reply as JSON: {"files": ["..."]}', + parse=parse_files(tree), + ) + print("located files:", ", ".join(files)) + + # Phase 1b — element-level localization inside the located files. + content = await c.scope("excerpts").step("read", lambda: excerpts(files)) + elements = await locator.ask( + f"Issue: {issue}\n\nLocated file excerpts:\n{content}\n\n" + "Name the specific elements (functions, classes, sections — or " + "new ones to add, and where) that the fix should touch, with one " + "line of rationale each. Reply as a single JSON string.", + parse=parse_text, + ) + print(f"located elements:\n{elements}") + + # Phase 2 — repair: a minimal patch confined to the located scope. + artifact = await fixer.work( + f"Resolve this issue with a MINIMAL change: {issue}\n\n" + f"Confine your edit to these locations:\nfiles: {', '.join(files)}\n" + f"elements:\n{elements}\n\nDo not refactor beyond the fix.", + expect_independent=True, + ) + await c.freeze() + + # Phase 3 — validation. + await c.verify(artifact, VERIFY) + verdict = await c.judge() + print("verdict:", verdict.selected_submission or "none", "—", *verdict.reasons) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_ISSUE)) diff --git a/examples/papers/agentverse.py b/examples/papers/agentverse.py new file mode 100644 index 0000000..9919d42 --- /dev/null +++ b/examples/papers/agentverse.py @@ -0,0 +1,124 @@ +"""AgentVerse: Facilitating Multi-Agent Collaboration and Exploring +Emergent Behaviors (Chen et al. 2023, arXiv:2308.10848) — recruit, +collaborate, evaluate, re-recruit. + +AgentVerse treats the *team composition itself* as part of the search: a +recruiter drafts the expert roles this specific task deserves, the recruits +collaborate, an evaluator scores the result — and if it is not satisfied, +the team is dissolved and a better-shaped one is recruited with the +feedback in hand. This score exploits an engine property: enrollment is +open-round-only, but a pure-``ask`` score never freezes, so it can *hire +new seats mid-run* — the recruit loop uses genuinely fresh agents each +attempt, not repainted ones. + + python examples/papers/agentverse.py [""] +""" + +import asyncio +import sys +from typing import Any, Mapping + +from h5i.orchestra import Conductor + +DEMO_TASK = ( + "Propose a realistic one-week plan to cut a web service's cloud bill " + "by 30% without hurting reliability." +) +MAX_ATTEMPTS = 2 +MAX_EXPERTS = 3 + + +def parse_roles(value: Any) -> list[str]: + roles = value.get("roles") if isinstance(value, Mapping) else value + if not isinstance(roles, list) or not roles: + raise ValueError('reply must be {"roles": ["", ...]}') + return [str(r).strip() for r in roles][:MAX_EXPERTS] + + +def parse_evaluation(value: Any) -> dict[str, Any]: + if not isinstance(value, Mapping) or "satisfied" not in value: + raise ValueError('reply must be {"satisfied": true|false, "feedback": "..."}') + return { + "satisfied": bool(value["satisfied"]), + "feedback": str(value.get("feedback", "")).strip(), + } + + +def parse_text(value: Any) -> str: + return value if isinstance(value, str) else str(value) + + +async def main(task: str) -> None: + async with Conductor(".", "agentverse-demo", launcher="resident", isolation="supervised") as c: + recruiter = await c.hire("recruiter", runtime="claude", model="claude-haiku-4-5") + evaluator = await c.hire("evaluator", runtime="claude", model="claude-haiku-4-5") + + feedback = "" + solution = "" + for attempt in range(1, MAX_ATTEMPTS + 1): + # Recruit: what team does THIS task (and this feedback) deserve? + roles = await recruiter.ask( + f"Task: {task}\n\n" + + ( + f"The previous team's result was rejected with feedback:\n" + f"{feedback}\n\nRecruit a better-shaped team.\n\n" + if feedback + else "" + ) + + f"Name at most {MAX_EXPERTS} expert roles whose combination " + 'would best solve this task. Reply as JSON: {"roles": ["..."]}', + parse=parse_roles, + ) + print(f"attempt {attempt} team: {'; '.join(roles)}") + + # The run never freezes (pure ask), so the round is still open — + # genuinely fresh seats can be hired for this attempt's team. + experts = [ + await c.hire( + f"a{attempt}-expert{i}", runtime="claude", model="claude-haiku-4-5" + ) + for i in range(len(roles)) + ] + + # Collaborate: role-played contributions, then synthesis. + contributions = await asyncio.gather( + *( + expert.ask( + f"You are: {role}\nTask: {task}\n" + + (f"Prior feedback to address: {feedback}\n" if feedback else "") + + "Contribute your role's part of the solution. " + "Reply as a single JSON string.", + parse=parse_text, + ) + for expert, role in zip(experts, roles) + ) + ) + solution = await recruiter.ask( + f"Task: {task}\n\nYour recruits contributed:\n\n" + + "\n\n".join( + f"[{role}]\n{text}" for role, text in zip(roles, contributions) + ) + + "\n\nSynthesize the team's final solution. Reply as a " + "single JSON string.", + parse=parse_text, + ) + + # Evaluate: keep the team's work, or dissolve and re-recruit. + evaluation = await evaluator.ask( + f"Task: {task}\n\nProposed solution:\n{solution}\n\nIs this " + "solution complete, concrete, and actionable? Reply as JSON: " + '{"satisfied": true|false, "feedback": ""}', + parse=parse_evaluation, + ) + if evaluation["satisfied"]: + print(f"attempt {attempt}: evaluator satisfied") + break + feedback = evaluation["feedback"] + print(f"attempt {attempt}: rejected — {feedback}") + + await c.note(f"agentverse: finished after recruiting {attempt} team(s)") + print(f"\nfinal solution:\n{solution}") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/alphacodium.py b/examples/papers/alphacodium.py new file mode 100644 index 0000000..f216c4f --- /dev/null +++ b/examples/papers/alphacodium.py @@ -0,0 +1,118 @@ +"""Code Generation with AlphaCodium: From Prompt Engineering to Flow +Engineering (Ridnik et al. 2024, arXiv:2401.08500) — reflect on the +problem, generate extra tests, then iterate. + +AlphaCodium's bet is *flow engineering*: most of the value comes before +and after generation, not from a better prompt. The flow: structured +problem reflection → reasoning about the given tests → generating +additional AI tests that probe edge cases → code → an iterate loop that +must keep both the public and the AI-generated tests green. Reflection +and test generation are validated-JSON ``ask`` turns; the iterate loop is +``verify`` + revise. + + python examples/papers/alphacodium.py [""] # default: implement quicksort with pytest +""" + +import asyncio +import json +import sys +from typing import Any, Mapping + +from h5i.orchestra import Conductor, Review, Verification + +DEMO_TASK = "implement quicksort with pytest" +VERIFY = ["pytest", "-q"] +MAX_ITERATIONS = 3 +N_AI_TESTS = 4 + + +def parse_reflection(value: Any) -> dict[str, Any]: + if not isinstance(value, Mapping): + raise ValueError("reply must be a JSON object") + missing = [k for k in ("goal", "inputs", "outputs", "edge_cases") if k not in value] + if missing: + raise ValueError(f"reflection is missing sections: {missing}") + return dict(value) + + +def parse_tests(value: Any) -> list[str]: + tests = value.get("tests") if isinstance(value, Mapping) else value + if not isinstance(tests, list) or not tests: + raise ValueError('reply must be {"tests": ["", ...]}') + return [str(t).strip() for t in tests][:N_AI_TESTS] + + +def describe(v: Verification) -> str: + line = ( + f"`{' '.join(v.command)}`: " + + ("applied cleanly" if v.applies_cleanly else "failed to apply") + + ", " + + ("tests passed" if v.tests_passed else "tests FAILED") + ) + if v.failure: + line += f"\nfailure: {v.failure}" + return line + + +async def main(task: str) -> None: + async with Conductor(".", "alphacodium-demo", launcher="resident", isolation="supervised") as c: + analyst = await c.hire("analyst", runtime="claude", model="claude-haiku-4-5") + coder = await c.hire("coder", runtime="codex", model="gpt-5.4-mini", effort="medium") + + # 1. Structured problem reflection — before any code. + reflection = await analyst.ask( + f"Problem: {task}\n\nReflect on it in structured form. Reply as " + 'JSON: {"goal": "...", "inputs": "...", "outputs": "...", ' + '"edge_cases": ["..."]}', + parse=parse_reflection, + ) + + # 2. AI test generation — probe the edge cases the reflection found. + ai_tests = await analyst.ask( + f"Problem: {task}\nReflection:\n{json.dumps(reflection, indent=2)}\n\n" + f"Design {N_AI_TESTS} ADDITIONAL tests that probe the edge cases " + "and failure modes a naive solution would miss. Reply as JSON: " + '{"tests": ["", ...]}', + parse=parse_tests, + ) + print("AI tests:", "; ".join(ai_tests)) + + # 3. Code against reflection + all tests (public and AI-generated). + artifact = await coder.work( + f"{task}\n\nProblem reflection:\n{json.dumps(reflection, indent=2)}\n\n" + "Beyond the task's own tests, your test suite MUST also cover:\n" + + "\n".join(f"- {t}" for t in ai_tests), + expect_independent=True, + ) + await c.freeze() + + # 4. Iterate: everything must stay green. + for iteration in range(1, MAX_ITERATIONS + 1): + verification = await c.verify(artifact, VERIFY) + if verification.applies_cleanly and verification.tests_passed: + print(f"iteration {iteration}: all tests green") + break + if iteration == MAX_ITERATIONS: + break + artifact = await coder.revise( + artifact, + Review( + reviewer="flow-iterate", + target=coder.id, + round=artifact.round, + body=( + "Verdict: REVISE\n\nThe suite (public + AI-generated " + f"tests) failed neutrally:\n{describe(verification)}\n\n" + "Fix the implementation; keep every test." + ), + referenced_artifacts=(artifact.id,), + ), + ) + print(f"iteration {iteration}: red — revised") + + verdict = await c.judge() + print("verdict:", verdict.selected_submission or "none", "—", *verdict.reasons) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/camel.py b/examples/papers/camel.py new file mode 100644 index 0000000..19cc38b --- /dev/null +++ b/examples/papers/camel.py @@ -0,0 +1,96 @@ +"""CAMEL: Communicative Agents for "Mind" Exploration of Large Language +Model Society (Li et al. 2023, arXiv:2303.17760) — inception-prompted role +playing. + +CAMEL's recipe for autonomous cooperation without drift: a task specifier +turns a vague idea into a concrete task, then two inception-prompted seats +play fixed roles — the user gives ONE instruction at a time and never +solves; the assistant solves exactly what was instructed and never leads — +until the user declares the task done. The inception prompts below are the +paper's role constraints, generalized; the alternating turns are plain +``ask`` calls, so the whole role-play is journaled and resumable. + + python examples/papers/camel.py [""] +""" + +import asyncio +import sys +from typing import Any, Mapping + +from h5i.orchestra import Conductor + +DEMO_IDEA = "design a rate limiter for a public HTTP API" +MAX_TURNS = 4 + +USER_INCEPTION = ( + "Never forget you are the USER and I am the ASSISTANT. You always " + "instruct; you never solve the task yourself and never switch roles. " + "Give me exactly ONE concrete instruction at a time, building on my " + "previous solutions. When the task is completely solved, set done=true." +) +ASSISTANT_INCEPTION = ( + "Never forget you are the ASSISTANT and I am the USER. You solve " + "exactly the instruction given — completely, concretely, no deferrals " + "— and you never give instructions back or switch roles." +) + + +def parse_instruction(value: Any) -> dict[str, Any]: + if not isinstance(value, Mapping) or "instruction" not in value: + raise ValueError('reply must be {"instruction": "...", "done": true|false}') + return { + "instruction": str(value["instruction"]).strip(), + "done": bool(value.get("done", False)), + } + + +def parse_text(value: Any) -> str: + return value if isinstance(value, str) else str(value) + + +async def main(idea: str) -> None: + async with Conductor(".", "camel-demo", launcher="resident", isolation="supervised") as c: + specifier = await c.hire("specifier", runtime="claude", model="claude-haiku-4-5") + user_role = await c.hire("user-role", runtime="claude", model="claude-haiku-4-5") + assistant_role = await c.hire( + "assistant-role", runtime="codex", model="gpt-5.4-mini", effort="medium" + ) + + # Task specification: vague idea → concrete, bounded task. + task = await specifier.ask( + f"Make this idea specific enough for two agents to execute in a " + f"few steps — one sentence, concrete deliverable: {idea}\n\n" + "Reply as a single JSON string.", + parse=parse_text, + ) + print(f"specified task: {task}") + + # Inception-prompted role-play: instruct → solve, one step at a time. + transcript = "" + for turn in range(1, MAX_TURNS + 1): + move = await user_role.ask( + f"{USER_INCEPTION}\n\nTask: {task}\n\nConversation so far:\n" + f"{transcript or '(start)'}\n\nReply as JSON: " + '{"instruction": "", ' + '"done": true|false}', + parse=parse_instruction, + ) + if move["done"]: + print(f"turn {turn}: user declares the task done") + break + print(f"turn {turn}: {move['instruction'][:100]}") + solution = await assistant_role.ask( + f"{ASSISTANT_INCEPTION}\n\nTask: {task}\n\nConversation so " + f"far:\n{transcript or '(start)'}\n\nInstruction: " + f"{move['instruction']}\n\nReply with your solution as a " + "single JSON string.", + parse=parse_text, + ) + transcript += f"USER: {move['instruction']}\nASSISTANT: {solution}\n\n" + + await c.note(f"camel role-play finished for: {task}") + print(f"\nrole-play transcript:\n{transcript}") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_IDEA)) diff --git a/examples/papers/chain_of_agents.py b/examples/papers/chain_of_agents.py new file mode 100644 index 0000000..6a61d2a --- /dev/null +++ b/examples/papers/chain_of_agents.py @@ -0,0 +1,92 @@ +"""Chain of Agents: Large Language Models Collaborating on Long-Context +Tasks (Zhang et al. 2024, arXiv:2406.02818) — sequential workers passing a +communication unit, then a manager. + +Instead of stuffing a long document into one context (lost-in-the-middle) +or retrieving fragments (lost evidence), CoA interleaves reading and +reasoning: the document is chunked, worker agents read one chunk each *in +order*, and each worker rewrites a running "communication unit" — carrying +forward exactly what matters for the query — before passing it on. A +manager who never sees the document answers from the final unit alone. The +source read is a journaled ``step``, so a resume replays the same document +even if the file changed. + + python examples/papers/chain_of_agents.py [""] [] + # defaults: summarize README.md of the current repo +""" + +import asyncio +import sys +from pathlib import Path + +from h5i.orchestra import Conductor + +DEMO_QUESTION = "Summarize the key points of this document in five bullet points." +DEMO_PATH = "README.md" +MAX_DOC_CHARS = 24_000 # stay under the journal's inline step cap +CHUNK_CHARS = 4_000 + + +def chunked(text: str) -> list[str]: + """Split on paragraph boundaries into ~CHUNK_CHARS pieces.""" + chunks: list[str] = [] + current = "" + for paragraph in text.split("\n\n"): + if current and len(current) + len(paragraph) > CHUNK_CHARS: + chunks.append(current) + current = "" + current += paragraph + "\n\n" + if current.strip(): + chunks.append(current) + return chunks + + +async def main(question: str, path: str) -> None: + async with Conductor(".", "coa-demo", launcher="resident", isolation="supervised") as c: + workers = [ + await c.hire("worker0", runtime="claude", model="claude-haiku-4-5"), + await c.hire("worker1", runtime="claude", model="claude-haiku-4-5"), + ] + manager = await c.hire("manager", runtime="claude", model="claude-haiku-4-5") + + # Journal the source read: a resume replays the exact same document. + text = await c.step( + "read-source", lambda: Path(path).read_text(encoding="utf-8")[:MAX_DOC_CHARS] + ) + chunks = chunked(text) + print(f"{path}: {len(text)} chars → {len(chunks)} chunk(s)") + + # Worker chain: strictly sequential — each unit depends on the last. + unit = "(none yet — you read the first segment)" + for i, chunk in enumerate(chunks): + unit = await workers[i % len(workers)].ask( + f"You are worker {i + 1}/{len(chunks)} in a chain reading a " + f"long document, one segment each.\nQuery: {question}\n\n" + f"Communication unit from the previous worker:\n{unit}\n\n" + f"Your segment:\n{chunk}\n\n" + "Rewrite the communication unit for the next worker: carry " + "forward everything relevant to the query, integrate new " + "evidence from your segment, drop what does not matter. " + "Reply as a single JSON string.", + parse=lambda v: v if isinstance(v, str) else str(v), + ) + + # The manager answers from the final unit alone — it never sees the + # document. + answer = await manager.ask( + f"Query: {question}\n\nAccumulated evidence from the worker " + f"chain:\n{unit}\n\nAnswer the query from this evidence only. " + "Reply as a single JSON string.", + parse=lambda v: v if isinstance(v, str) else str(v), + ) + await c.note(f"chain-of-agents over {path} ({len(chunks)} chunks)") + print(f"\nanswer:\n{answer}") + + +if __name__ == "__main__": + asyncio.run( + main( + sys.argv[1] if len(sys.argv) > 1 else DEMO_QUESTION, + sys.argv[2] if len(sys.argv) > 2 else DEMO_PATH, + ) + ) diff --git a/examples/papers/chain_of_verification.py b/examples/papers/chain_of_verification.py new file mode 100644 index 0000000..3dfe0c9 --- /dev/null +++ b/examples/papers/chain_of_verification.py @@ -0,0 +1,104 @@ +"""Chain-of-Verification Reduces Hallucination in Large Language Models +(Dhuliawala et al. 2023, arXiv:2309.11495) — draft, verify factored, +revise. + +CoVe's four steps: draft an answer; plan verification questions that would +expose its errors; answer those questions *independently of the draft* so +its hallucinations cannot leak into the checks (the paper's best-performing +"factored" variant); then revise the draft against the evidence. h5i's +seat isolation gives the factored property for free: the verification +questions are answered by seats that never saw the draft. + + python examples/papers/chain_of_verification.py [""] +""" + +import asyncio +import sys +from typing import Any + +from h5i.orchestra import Agent, Conductor + +DEMO_QUESTION = ( + "Name three programming languages that had garbage collection before " + "1980, with the year each first appeared." +) +MAX_CHECKS = 4 + + +def parse_text(value: Any) -> str: + return value if isinstance(value, str) else str(value) + + +def parse_questions(value: Any) -> list[str]: + if not isinstance(value, list) or not value: + raise ValueError("reply must be a non-empty JSON array of question strings") + return [str(q).strip() for q in value][:MAX_CHECKS] + + +async def main(question: str) -> None: + async with Conductor(".", "cove-demo", launcher="resident", isolation="supervised") as c: + drafter = await c.hire("drafter", runtime="claude", model="claude-haiku-4-5") + checkers = [ + await c.hire(f"checker{i}", runtime="claude", model="claude-haiku-4-5") + for i in range(2) + ] + + # 1. Baseline draft. + draft = await drafter.ask( + f"{question}\n\nReply as a single JSON string.", parse=parse_text + ) + print(f"draft:\n{draft}\n") + + # 2. Plan verification questions targeting the draft's claims. + checks = await drafter.ask( + f"Question: {question}\nDraft answer:\n{draft}\n\n" + f"Plan at most {MAX_CHECKS} verification questions that would " + "expose factual errors in this draft — one independent, " + "self-contained question per claim. Reply as a JSON array of " + "strings.", + parse=parse_questions, + ) + + # 3. Execute verifications FACTORED: checker seats never saw the + # draft, so its errors cannot leak into the evidence. Parallel + # across seats, sequential within one. + assignments: dict[str, tuple[Agent, list[int]]] = {} + for i in range(len(checks)): + checker = checkers[i % len(checkers)] + assignments.setdefault(checker.id, (checker, []))[1].append(i) + + async def answer_checks(checker: Agent, indices: list[int]) -> list[tuple[int, str]]: + return [ + ( + i, + await checker.ask( + f"{checks[i]}\n\nAnswer factually and concisely. " + "Reply as a single JSON string.", + parse=parse_text, + ), + ) + for i in indices + ] + + groups = await asyncio.gather( + *(answer_checks(chk, idx) for chk, idx in assignments.values()) + ) + evidence = dict(pair for group in groups for pair in group) + for i, q in enumerate(checks): + print(f"check: {q}\n → {evidence[i]}") + + # 4. Revise the draft against the independent evidence. + final = await drafter.ask( + f"Question: {question}\nYour draft:\n{draft}\n\n" + "Independent verification results:\n" + + "".join(f"Q: {q}\nA: {evidence[i]}\n" for i, q in enumerate(checks)) + + "\nRewrite the answer, keeping only claims the evidence " + "supports and correcting the rest. Reply as a single JSON string.", + parse=parse_text, + ) + await c.note(f"chain-of-verification: {len(checks)} factored checks") + print(f"\nverified answer:\n{final}") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_QUESTION)) diff --git a/examples/papers/chatdev.py b/examples/papers/chatdev.py new file mode 100644 index 0000000..a22bfac --- /dev/null +++ b/examples/papers/chatdev.py @@ -0,0 +1,132 @@ +"""ChatDev: Communicative Agents for Software Development (Qian et al. +2024, arXiv:2307.07924) — a chat chain: phases, each a two-role dialogue. + +ChatDev decomposes the waterfall into phases (design → coding → review → +testing → documentation) and staffs every phase with exactly two roles: an +instructor who steers and an assistant who produces, talking until the +phase's deliverable is agreed. Here the design and documentation phases are +alternating ``ask`` turns; the coding phase is a real work turn; review and +testing are ChatDev's "thought instructions" mapped onto h5i's native +review/revise turns and neutral verification. + + python examples/papers/chatdev.py [""] # default: implement quicksort with pytest +""" + +import asyncio +import sys +from typing import Any, Mapping + +from h5i.orchestra import Conductor, Review, Verification + +DEMO_TASK = "implement quicksort with pytest" +VERIFY = ["pytest", "-q"] +DESIGN_TURNS = 2 +REVIEW_ROUNDS = 2 +TEST_ROUNDS = 2 + + +def parse_move(value: Any) -> dict[str, Any]: + if not isinstance(value, Mapping) or "message" not in value: + raise ValueError('reply must be {"message": "...", "settled": true|false}') + return {"message": str(value["message"]).strip(), "settled": bool(value.get("settled", False))} + + +def parse_text(value: Any) -> str: + return value if isinstance(value, str) else str(value) + + +def describe(v: Verification) -> str: + line = ( + f"`{' '.join(v.command)}`: " + + ("applied cleanly" if v.applies_cleanly else "failed to apply") + + ", " + + ("tests passed" if v.tests_passed else "tests FAILED") + ) + if v.failure: + line += f"\nfailure: {v.failure}" + return line + + +async def main(task: str) -> None: + async with Conductor(".", "chatdev-demo", launcher="resident", isolation="supervised") as c: + instructor = await c.hire("instructor", runtime="claude", model="claude-haiku-4-5") + programmer = await c.hire("programmer", runtime="codex", model="gpt-5.4-mini", effort="medium") + reviewer = await c.hire("reviewer", runtime="claude", model="claude-haiku-4-5") + + # Phase 1 — design: instructor ↔ programmer until the spec settles. + dialogue = "" + spec = "" + for turn in range(1, DESIGN_TURNS + 1): + move = await instructor.ask( + f"You are the instructor in the DESIGN phase for: {task}\n" + f"Dialogue so far:\n{dialogue or '(start)'}\n\n" + "Steer the design: raise the next decision to settle, or — " + "if the design is complete — restate the agreed spec and set " + 'settled=true. Reply as JSON: {"message": "...", ' + '"settled": true|false}', + parse=parse_move, + ) + if move["settled"]: + spec = move["message"] + break + reply = await programmer.ask( + f"You are the assistant in the DESIGN phase for: {task}\n" + f"Dialogue so far:\n{dialogue}\nInstructor: {move['message']}\n\n" + "Answer the design question concretely. Reply as a single " + "JSON string.", + parse=parse_text, + ) + dialogue += f"instructor: {move['message']}\nprogrammer: {reply}\n" + spec = dialogue + print(f"design settled:\n{spec[:200]}") + + # Phase 2 — coding: one real work turn against the agreed design. + artifact = await programmer.work( + f"{task}\n\nAgreed design from the design phase:\n{spec}", + expect_independent=True, + ) + await c.freeze() + + # Phase 3 — code review: reviewer ↔ programmer on the artifact. + for round_no in range(1, REVIEW_ROUNDS + 1): + review = await reviewer.review(artifact) + if review.approved: + print(f"review round {round_no}: approved") + break + artifact = await programmer.revise(artifact, review) + + # Phase 4 — testing: neutral execution, failures loop back. + for round_no in range(1, TEST_ROUNDS + 1): + verification = await c.verify(artifact, VERIFY) + if verification.applies_cleanly and verification.tests_passed: + print(f"test round {round_no}: green") + break + if round_no == TEST_ROUNDS: + break + artifact = await programmer.revise( + artifact, + Review( + reviewer="tester", + target=programmer.id, + round=artifact.round, + body=f"Verdict: REVISE\n\n{describe(verification)}", + referenced_artifacts=(artifact.id,), + ), + ) + + # Phase 5 — documentation: the user manual, recorded on the run. + manual = await instructor.ask( + f"Write a short user manual for what was just built ({task}): " + "what it does, how to run it, how to run its tests. Reply as a " + "single JSON string.", + parse=parse_text, + ) + await c.note(f"chatdev manual:\n{manual}") + + verdict = await c.judge() + print("verdict:", verdict.selected_submission or "none", "—", *verdict.reasons) + print(f"\nmanual:\n{manual[:400]}") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/chateval.py b/examples/papers/chateval.py new file mode 100644 index 0000000..e99bdfb --- /dev/null +++ b/examples/papers/chateval.py @@ -0,0 +1,93 @@ +"""ChatEval: Towards Better LLM-based Evaluators through Multi-Agent Debate +(Chan et al. 2024, arXiv:2308.07201) — a persona-diverse referee team that +debates before scoring. + +A single LLM judge is biased and noisy; ChatEval replaces it with a panel of +*distinct personas* that communicate: in the one-by-one protocol each +evaluator sees what earlier evaluators said before adding its own ballot. +``patterns.judge_panel`` polls its judges independently, so this score opens +the pattern up: same evidence-grounded ``Ballot``s and citation validation, +but the panel speaks in sequence with the running transcript threaded +through, and the mean-score verdict is recorded over the debated ballots. + + python examples/papers/chateval.py [""] # default: implement quicksort with pytest +""" + +import asyncio +import sys + +from h5i.orchestra import Conductor, patterns + +DEMO_TASK = "implement quicksort with pytest" +VERIFY = ["pytest", "-q"] +PERSONAS = ( + ("critic-correct", "You care only about functional correctness and test rigor."), + ("critic-clarity", "You care only about readability and maintainability."), + ("critic-scope", "You care only about scope discipline: no gold-plating, no gaps."), +) + + +async def main(task: str) -> None: + async with Conductor(".", "chateval-demo", launcher="resident", isolation="supervised") as c: + alice = await c.hire("alice", runtime="claude", model="claude-haiku-4-5") + bob = await c.hire("bob", runtime="codex", model="gpt-5.4-mini", effort="medium") + panel = [ + await c.hire(name, runtime="claude", model="claude-haiku-4-5") + for name, _ in PERSONAS + ] + + # Two candidates to referee: independent attempts, sealed, then + # neutrally verified so the panel has real evidence to cite. + a, b = await asyncio.gather( + alice.work(task, expect_independent=True), + bob.work(task, expect_independent=True), + ) + await c.freeze() + await c.verify(a, VERIFY) + await c.verify(b, VERIFY) + + status = await c.status() + candidate_ids = [s.id for s in status.submissions] + valid_ids = {s.id for s in status.submissions} | {v.id for v in status.verifications} + evidence = patterns.render_evidence(status) + + # One-by-one communication: each persona reads the prior ballots + # before casting its own — the debate part of ChatEval. + transcript: list[str] = [] + ballots: list[patterns.Ballot] = [] + for judge, (_, persona) in zip(panel, PERSONAS): + heard = ( + "Earlier evaluators said:\n" + "\n".join(transcript) + if transcript + else "You speak first." + ) + prompt = ( + f"You are one evaluator on a referee team. Persona: {persona}\n" + f"Task under evaluation: {task}\n\n{heard}\n\n" + "Score EACH candidate 0-10 from your persona's standpoint, " + "grounding every rationale in the recorded evidence (cite the " + "exact ids you used). You may rebut earlier evaluators.\n\n" + f"Candidates: {', '.join(candidate_ids)}\n\nEvidence:\n{evidence}\n\n" + 'Reply as JSON: {"ballots": [{"artifact_id": "", ' + '"score": <0-10>, "rationale": "", ' + '"cited_ids": ["", …]}]}.' + ) + card = await patterns.ask_with_valid_citations( + judge, prompt, valid_ids, candidate_ids + ) + ballots.extend(card) + transcript.extend( + f"[{judge.id}] {b.artifact_id}: {b.score}/10 — {b.rationale}" for b in card + ) + + # Aggregate the debated ballots with the stock mean-score rule. + verdict = await c.judge( + lambda run: patterns.mean_score_verdict(ballots, len(panel), run) + ) + for line in transcript: + print(line[:140]) + print("\nverdict:", verdict.selected_submission or "none", "—", *verdict.reasons) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/codet.py b/examples/papers/codet.py new file mode 100644 index 0000000..41493f0 --- /dev/null +++ b/examples/papers/codet.py @@ -0,0 +1,95 @@ +"""CodeT: Code Generation with Generated Tests (Chen et al. 2022, +arXiv:2207.10397) — dual execution agreement: rank candidates by consensus +with an independent test suite. + +Sample many solutions AND generate tests independently, then trust the +candidates the tests agree on: a solution's score is how well it passes +tests it never saw. Here n solution seats attempt the task in parallel +while a test designer writes the suite blind; after the freeze every +solution seat applies the granted suite as materials, each combined +candidate is neutrally executed, and a verdict policy ranks by agreement +(suite passed, then smaller diff). The consensus signal is per-suite; for +per-test granularity, parse the verifier's capture output. + + python examples/papers/codet.py [""] # default: implement quicksort with pytest +""" + +import asyncio +import sys + +from h5i.orchestra import Conductor, Verdict + +DEMO_TASK = "implement quicksort with pytest" +VERIFY = ["pytest", "-q"] + + +async def main(task: str) -> None: + async with Conductor(".", "codet-demo", launcher="resident", isolation="supervised") as c: + solvers = [ + await c.hire("sol0", runtime="claude", model="claude-haiku-4-5"), + await c.hire("sol1", runtime="claude", model="claude-haiku-4-5"), + await c.hire("sol2", runtime="codex", model="gpt-5.4-mini", effort="medium"), + ] + test_designer = await c.hire( + "test-designer", runtime="claude", model="claude-haiku-4-5" + ) + + # Solutions and tests sampled independently — neither sees the other. + results = await asyncio.gather( + *(s.work(task, expect_independent=True) for s in solvers), + test_designer.work( + f"Write ONLY a thorough test suite for: {task}\nCover basic, " + "edge, and adversarial cases. Do NOT write the implementation.", + expect_independent=True, + ), + ) + suite = results[-1] + await c.freeze() + + # Dual execution: every solution takes on the same blind suite and + # is neutrally executed against it. + agreements: dict[str, bool] = {} + for solver in solvers: + candidate = await solver.work( + "Apply the granted teammate artifact — an independently " + "written test suite for your task — into your worktree " + "alongside your implementation, WITHOUT changing either " + "the tests or your implementation logic.", + materials=[suite], + ) + verification = await c.verify(candidate, VERIFY) + agreements[candidate.id] = ( + verification.applies_cleanly and verification.tests_passed + ) + print( + f"{solver.id}: {'agrees with' if agreements[candidate.id] else 'rejected by'} " + "the blind suite" + ) + + # Rank by dual execution agreement; ties go to the smaller diff. + def dual_agreement(run) -> Verdict: + candidates = [s for s in run.submissions if s.id in agreements] + ranked = sorted( + candidates, + key=lambda s: (not agreements[s.id], s.files_changed, s.insertions, s.id), + ) + winner = ranked[0] + passing = sum(agreements.values()) + return Verdict( + method=f"codet:dual-execution({passing}/{len(candidates)} agree)", + decided_by="codet-demo score", + selected_submission=winner.id if agreements[winner.id] else None, + can_auto_apply=False, + reasons=( + f"{winner.id} passed the independently generated suite" + if agreements[winner.id] + else "no candidate agreed with the blind test suite", + ), + ) + + verdict = await c.judge(dual_agreement) + print("\nverdict:", verdict.selected_submission or "none", "—", *verdict.reasons) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/constitutional_ai.py b/examples/papers/constitutional_ai.py new file mode 100644 index 0000000..affae30 --- /dev/null +++ b/examples/papers/constitutional_ai.py @@ -0,0 +1,91 @@ +"""Constitutional AI: Harmlessness from AI Feedback (Bai et al. 2022, +arXiv:2212.08073) — the supervised critique-revision loop, with an +explicit constitution. + +CAI's first stage replaces human feedback with principle-grounded +self-critique: a draft response is checked against each principle of a +written constitution; every violation produces a critique, and the drafter +revises against the collected critiques — repeating until the response is +clean. The constitution below is a small generic one; edit it to govern +whatever matters in your domain (the loop is principle-agnostic). Critique +turns are per-principle ``ask``s by a critic seat of the same model. + + python examples/papers/constitutional_ai.py [""] +""" + +import asyncio +import sys +from typing import Any, Mapping + +from h5i.orchestra import Conductor + +DEMO_REQUEST = ( + "My coworker keeps taking credit for my work. Draft a message I can " + "send them tonight to make them stop." +) +CONSTITUTION = ( + "Be helpful: actually address the request instead of deflecting it.", + "Avoid harm: do not encourage retaliation, escalation, or deception.", + "Be honest: no fabricated facts, no overstated certainty.", + "Respect autonomy: offer options and trade-offs, not commands.", +) +MAX_ROUNDS = 2 + + +def parse_critique(value: Any) -> dict[str, Any]: + if not isinstance(value, Mapping) or "violates" not in value: + raise ValueError('reply must be {"violates": true|false, "critique": "..."}') + return { + "violates": bool(value["violates"]), + "critique": str(value.get("critique", "")).strip(), + } + + +def parse_text(value: Any) -> str: + return value if isinstance(value, str) else str(value) + + +async def main(request: str) -> None: + async with Conductor(".", "cai-demo", launcher="resident", isolation="supervised") as c: + drafter = await c.hire("drafter", runtime="claude", model="claude-haiku-4-5") + critic = await c.hire("critic", runtime="claude", model="claude-haiku-4-5") + + response = await drafter.ask( + f"{request}\n\nReply as a single JSON string.", parse=parse_text + ) + + for round_no in range(1, MAX_ROUNDS + 1): + # Critique the response against each principle separately. + critiques: list[str] = [] + for i, principle in enumerate(CONSTITUTION, 1): + verdict = await critic.ask( + f"Constitutional principle: {principle}\n\n" + f"Request: {request}\n\nResponse under review:\n{response}\n\n" + "Does the response violate THIS principle? Reply as " + 'JSON: {"violates": true|false, "critique": ""}', + parse=parse_critique, + ) + if verdict["violates"]: + critiques.append(f"(principle {i}: {principle}) {verdict['critique']}") + if not critiques: + print(f"round {round_no}: no principle violated") + break + print(f"round {round_no}: {len(critiques)} principle(s) violated") + + # Revise against the collected critiques. + response = await drafter.ask( + f"Request: {request}\n\nYour previous response:\n{response}\n\n" + "Constitutional critiques of it:\n" + + "\n".join(f"- {ct}" for ct in critiques) + + "\n\nRewrite the response to satisfy every critique while " + "staying genuinely helpful. Reply as a single JSON string.", + parse=parse_text, + ) + + await c.note(f"constitutional-ai: finished after round {round_no}") + print(f"\nfinal response:\n{response}") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_REQUEST)) diff --git a/examples/papers/critic.py b/examples/papers/critic.py new file mode 100644 index 0000000..00d7077 --- /dev/null +++ b/examples/papers/critic.py @@ -0,0 +1,85 @@ +"""CRITIC: Large Language Models Can Self-Correct with Tool-Interactive +Critiquing (Gou et al. 2024, arXiv:2305.11738) — verify → critique → correct. + +A model's unaided opinion of its own output is unreliable, so CRITIC grounds +every critique in *external tools*. Each cycle: run the toolbox against the +candidate (``conductor.verify``, one neutral command per tool, each in a +fresh sandboxed worktree), have the same model read the raw tool evidence +and turn it into a concrete critique (an ``ask`` data turn), then correct +against that critique (``revise``). All tools green ends the loop. Extend +``TOOLS`` with linters or scanners to widen the toolbox. + + python examples/papers/critic.py [""] # default: implement quicksort with pytest +""" + +import asyncio +import sys + +from h5i.orchestra import Conductor, Review, Verification + +DEMO_TASK = "implement quicksort with pytest" +TOOLS: tuple[list[str], ...] = ( + ["python", "-m", "compileall", "-q", "."], + ["pytest", "-q"], +) +MAX_CYCLES = 3 + + +def describe(v: Verification) -> str: + line = ( + f"`{' '.join(v.command)}`: " + + ("applied cleanly" if v.applies_cleanly else "failed to apply") + + ", " + + ("passed" if v.tests_passed else "FAILED") + ) + if v.failure: + line += f" — {v.failure}" + return line + + +async def main(task: str) -> None: + async with Conductor(".", "critic-demo", launcher="resident", isolation="supervised") as c: + solver = await c.hire("solver", runtime="claude", model="claude-haiku-4-5") + + artifact = await solver.work(task, expect_independent=True) + await c.freeze() + + for cycle in range(1, MAX_CYCLES + 1): + # Interact with the tools: neutral evidence, not self-opinion. + runs = [await c.verify(artifact, tool) for tool in TOOLS] + evidence = "\n".join(describe(v) for v in runs) + if all(v.applies_cleanly and v.tests_passed for v in runs): + print(f"cycle {cycle}: all {len(TOOLS)} tools green") + break + print(f"cycle {cycle}: tools rejected the candidate") + if cycle == MAX_CYCLES: + break + + # Critique conditioned on the tool outputs. + critique = await solver.ask( + "External tools ran against your submission:\n" + f"{evidence}\n\n" + "Write a tool-grounded critique: list each concrete problem " + "the evidence shows and the fix you will make. Reply as a " + "single JSON string.", + parse=lambda v: v if isinstance(v, str) else str(v), + ) + + # Correct against the critique. + artifact = await solver.revise( + artifact, + Review( + reviewer="tool-critic", + target=solver.id, + round=artifact.round, + body=f"Verdict: REVISE\n\n{critique}\n\nTool evidence:\n{evidence}", + referenced_artifacts=(artifact.id,), + ), + ) + + verdict = await c.judge() + print("verdict:", verdict.selected_submission or "none", "—", *verdict.reasons) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/dylan.py b/examples/papers/dylan.py new file mode 100644 index 0000000..c3fc3bd --- /dev/null +++ b/examples/papers/dylan.py @@ -0,0 +1,130 @@ +"""A Dynamic LLM-Powered Agent Network for Task-Oriented Agent +Collaboration (Liu et al. 2023, arXiv:2310.02170) — DyLAN: the team is +mutable state. + +Fixed teams waste turns on agents that contribute nothing. DyLAN runs +rounds of collaboration and *prunes the roster as it goes*: after each +round a ranker scores every active agent's contribution (the agent +importance signal), the least valuable seat is deactivated, and the +exchange stops early once the survivors agree. Team membership is an +ordinary Python list — deactivation is `active.remove(...)`, which no +static workflow graph can express. + + python examples/papers/dylan.py [""] +""" + +import asyncio +import sys +from collections import Counter +from typing import Any, Mapping + +from h5i.orchestra import Conductor + +DEMO_QUESTION = ( + "You flip two fair coins. Given that at least one is heads, what is " + "the probability that both are heads?" +) +MAX_ROUNDS = 3 +MIN_TEAM = 2 + + +def parse_position(value: Any) -> dict[str, str]: + if not isinstance(value, Mapping) or "answer" not in value: + raise ValueError('reply must be {"reasoning": "...", "answer": "..."}') + return { + "answer": str(value["answer"]).strip(), + "reasoning": str(value.get("reasoning", "")).strip(), + } + + +def parse_scores(names: list[str]): + def parse(value: Any) -> dict[str, float]: + scores = value.get("scores") if isinstance(value, Mapping) else None + if not isinstance(scores, Mapping) or set(scores) != set(names): + raise ValueError( + f'reply must be {{"scores": {{: <0-10>}}}} covering exactly {names}' + ) + return {k: max(0.0, min(10.0, float(v))) for k, v in scores.items()} + + return parse + + +def canon(answer: str) -> str: + return answer.strip().lower() + + +async def main(question: str) -> None: + base = ( + f"{question}\n\nReply as JSON: " + '{"reasoning": "", "answer": ""}' + ) + async with Conductor(".", "dylan-demo", launcher="resident", isolation="supervised") as c: + active = [ + await c.hire("solver0", runtime="claude", model="claude-haiku-4-5"), + await c.hire("solver1", runtime="claude", model="claude-haiku-4-5"), + await c.hire("solver2", runtime="codex", model="gpt-5.4-mini", effort="medium"), + await c.hire("solver3", runtime="claude", model="claude-haiku-4-5"), + ] + ranker = await c.hire("ranker", runtime="claude", model="claude-haiku-4-5") + importance: dict[str, float] = {a.id: 0.0 for a in active} + + positions: dict[str, dict[str, str]] = {} + for round_no in range(1, MAX_ROUNDS + 1): + # One collaboration round: active agents answer, seeing the + # other active agents' previous positions. + prompts = [] + for agent in active: + heard = "\n\n".join( + f"[{aid}] answer: {p['answer']}\nreasoning: {p['reasoning']}" + for aid, p in positions.items() + if aid != agent.id + ) + prompts.append( + (f"Peers' previous positions:\n\n{heard}\n\n" if heard else "") + + f"Round {round_no}.\n\n{base}" + ) + answers = await asyncio.gather( + *(a.ask(p, parse=parse_position) for a, p in zip(active, prompts)) + ) + positions = {a.id: pos for a, pos in zip(active, answers)} + + # Early stop: the survivors agree. + if len({canon(p["answer"]) for p in positions.values()}) == 1: + print(f"round {round_no}: consensus among {len(active)} active agents") + break + + # Agent importance: rank contributions, deactivate the weakest. + names = [a.id for a in active] + rendered = "\n\n".join( + f"[{aid}] answer: {p['answer']}\nreasoning: {p['reasoning']}" + for aid, p in positions.items() + ) + scores = await ranker.ask( + f"Question: {question}\n\nContributions this round:\n\n" + f"{rendered}\n\nScore each agent's contribution 0-10 for " + "correctness and usefulness to the team. Reply as JSON: " + '{"scores": {"": <0-10>, ...}} covering every agent.', + parse=parse_scores(names), + ) + for aid, s in scores.items(): + importance[aid] += s + if len(active) > MIN_TEAM and round_no < MAX_ROUNDS: + weakest = min(active, key=lambda a: scores[a.id]) + active.remove(weakest) + del positions[weakest.id] + print( + f"round {round_no}: deactivated {weakest.id} " + f"(score {scores[weakest.id]:.1f}); roster now " + f"{[a.id for a in active]}" + ) + + votes = Counter(canon(p["answer"]) for p in positions.values()) + winner, n = votes.most_common(1)[0] + ranked = sorted(importance.items(), key=lambda kv: -kv[1]) + await c.note(f"dylan: '{winner}' ({n}/{len(positions)}); importance {ranked}") + print(f"\nfinal answer: {winner} ({n}/{len(positions)} active votes)") + print("agent importance:", ", ".join(f"{k}={v:.1f}" for k, v in ranked)) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_QUESTION)) diff --git a/examples/papers/exchange_of_thought.py b/examples/papers/exchange_of_thought.py new file mode 100644 index 0000000..910de60 --- /dev/null +++ b/examples/papers/exchange_of_thought.py @@ -0,0 +1,116 @@ +"""Exchange-of-Thought: Enhancing Large Language Model Capabilities through +Cross-Model Communication (Yin et al. 2023, arXiv:2312.01823) — four +communication topologies over the same seats. + +EoT's contribution is treating the *communication network* as the design +variable: Memory (bus — everyone sees the full shared history), Report +(star — spokes exchange only with a hub), Relay (ring — each agent hears +only its predecessor), and Debate (tree — pairwise exchange feeding a +parent). Each paradigm here is a small function that decides who-sees-what +before the same round of ``ask`` turns; termination is confidence-based — +an agent that keeps its answer across rounds gains confidence, and the +exchange stops when everyone is confident. Topology is just Python. + + python examples/papers/exchange_of_thought.py [""] [memory|report|relay|debate] +""" + +import asyncio +import sys +from collections import Counter +from typing import Any, Mapping + +from h5i.orchestra import Conductor + +DEMO_QUESTION = ( + "A snail climbs 3 meters up a 10-meter wall each day and slips back 2 " + "meters each night. On which day does it reach the top?" +) +N_AGENTS = 3 +MAX_ROUNDS = 3 +CONFIDENT_AFTER = 2 # identical answers in a row → confident + + +def parse_position(value: Any) -> dict[str, str]: + if not isinstance(value, Mapping) or "answer" not in value: + raise ValueError('reply must be {"reasoning": "...", "answer": "..."}') + return { + "answer": str(value["answer"]).strip(), + "reasoning": str(value.get("reasoning", "")).strip(), + } + + +def canon(answer: str) -> str: + return answer.strip().lower() + + +def visible_peers(topology: str, me: int, n: int) -> list[int]: + """Who agent `me` hears from — the entire difference between paradigms.""" + if topology == "memory": # bus: everyone + return [j for j in range(n) if j != me] + if topology == "report": # star: spokes hear the hub (agent 0), hub hears all + return [j for j in range(n) if j != me] if me == 0 else [0] + if topology == "relay": # ring: only the predecessor + return [(me - 1) % n] + if topology == "debate": # tree: siblings pair up; the root (0) hears both + return [j for j in range(n) if j != me] if me == 0 else [me % 2 + 1] + raise ValueError(f"unknown topology '{topology}'") + + +async def main(question: str, topology: str) -> None: + base = ( + f"{question}\n\nReply as JSON: " + '{"reasoning": "", "answer": ""}' + ) + async with Conductor(".", f"eot-{topology}-demo", launcher="resident", isolation="supervised") as c: + agents = [ + await c.hire("hub" if i == 0 else f"node{i}", runtime="claude", model="claude-haiku-4-5") + for i in range(N_AGENTS) + ] + + # Round 0: independent positions. + positions = list( + await asyncio.gather(*(a.ask(base, parse=parse_position) for a in agents)) + ) + streak = [1] * N_AGENTS # rounds each agent has kept its answer + + for round_no in range(1, MAX_ROUNDS + 1): + if all(s >= CONFIDENT_AFTER for s in streak): + print(f"all agents confident before round {round_no}") + break + prompts = [] + for i in range(N_AGENTS): + heard = "\n\n".join( + f"[{agents[j].id}] answer: {positions[j]['answer']}\n" + f"reasoning: {positions[j]['reasoning']}" + for j in visible_peers(topology, i, N_AGENTS) + ) + prompts.append( + f"Communication round {round_no} (topology: {topology}). " + f"You received these positions:\n\n{heard}\n\n" + f"Reconsider and answer again.\n\n{base}" + ) + updated = list( + await asyncio.gather( + *(a.ask(p, parse=parse_position) for a, p in zip(agents, prompts)) + ) + ) + for i in range(N_AGENTS): + same = canon(updated[i]["answer"]) == canon(positions[i]["answer"]) + streak[i] = streak[i] + 1 if same else 1 + positions = updated + print( + f"round {round_no}: " + f"{len({canon(p['answer']) for p in positions})} distinct answer(s), " + f"confidence streaks {streak}" + ) + + votes = Counter(canon(p["answer"]) for p in positions) + winner, n = votes.most_common(1)[0] + await c.note(f"exchange-of-thought[{topology}]: '{winner}' with {n}/{N_AGENTS}") + print(f"\nfinal answer ({topology}): {winner} ({n}/{N_AGENTS})") + + +if __name__ == "__main__": + question = sys.argv[1] if len(sys.argv) > 1 else DEMO_QUESTION + chosen = sys.argv[2] if len(sys.argv) > 2 else "relay" + asyncio.run(main(question, chosen)) diff --git a/examples/papers/graph_of_thoughts.py b/examples/papers/graph_of_thoughts.py new file mode 100644 index 0000000..a72fc6b --- /dev/null +++ b/examples/papers/graph_of_thoughts.py @@ -0,0 +1,128 @@ +"""Graph of Thoughts: Solving Elaborate Problems with Large Language Models +(Besta et al. 2024, arXiv:2308.09687) — thought transformations on an +arbitrary DAG. + +GoT generalizes chains and trees: a thought is a vertex, and the operations +are *generate* (branch k children), *score*, *keep-best*, *aggregate* +(merge several thoughts into a new one — the transformation trees cannot +express), and *refine* (a self-loop). Here each operation is one ``ask`` +data turn and the graph is a plain Python dict of vertices and edges, so +the score prints the actual DAG it executed — the define-by-run journal is +the graph. + + python examples/papers/graph_of_thoughts.py [""] +""" + +import asyncio +import sys +from typing import Any + +from h5i.orchestra import Conductor + +DEMO_TASK = ( + "Design a step-by-step plan to migrate a small team's monolith web app " + "to two or three services without a big-bang rewrite." +) +K_GENERATE = 3 # children of the root +KEEP_BEST = 2 # thoughts that survive scoring into the aggregate + + +def parse_thoughts(value: Any) -> list[str]: + if not isinstance(value, list) or not value: + raise ValueError("reply must be a non-empty JSON array of strings") + return [str(t).strip() for t in value][:K_GENERATE] + + +def parse_scores(expected: int): + def parse(value: Any) -> list[float]: + scores = value.get("scores") if isinstance(value, dict) else value + if not isinstance(scores, list) or len(scores) != expected: + raise ValueError(f'reply must be {{"scores": [<{expected} numbers 0-10>]}}') + return [max(0.0, min(10.0, float(s))) for s in scores] + + return parse + + +def parse_text(value: Any) -> str: + return value if isinstance(value, str) else str(value) + + +async def main(task: str) -> None: + async with Conductor(".", "got-demo", launcher="resident", isolation="supervised") as c: + generator = await c.hire("generator", runtime="claude", model="claude-haiku-4-5") + scorer = await c.hire("scorer", runtime="claude", model="claude-haiku-4-5") + merger = await c.hire("merger", runtime="claude", model="claude-haiku-4-5") + + # The graph: vertices are thoughts, edges record which operation + # produced what from what. + vertices: dict[str, str] = {"root": task} + edges: list[tuple[str, str, str]] = [] # (from, op, to) + + # generate(k): branch the root into k independent approaches. + approaches = await generator.ask( + f"Task: {task}\n\nPropose {K_GENERATE} genuinely different " + "approaches (different strategies, not rephrasings), each as a " + "compact plan. Reply as a JSON array of strings.", + parse=parse_thoughts, + ) + for i, thought in enumerate(approaches): + vertices[f"g{i}"] = thought + edges.append(("root", "generate", f"g{i}")) + + # score + keep-best(2). + numbered = "\n".join( + f"--- thought {i + 1} ---\n{t}" for i, t in enumerate(approaches) + ) + scores = await scorer.ask( + f"Task: {task}\n\nCandidate thoughts:\n{numbered}\n\nScore each " + "0-10 for how promising it is. Reply as JSON: " + f'{{"scores": [<{len(approaches)} numbers>]}}', + parse=parse_scores(len(approaches)), + ) + ranked = sorted(range(len(approaches)), key=lambda i: -scores[i]) + kept = ranked[:KEEP_BEST] + print("scores:", ", ".join(f"g{i}={scores[i]:.1f}" for i in range(len(scores)))) + + # aggregate: merge the survivors into one thought — the operation + # that makes it a graph rather than a tree. + merged = await merger.ask( + f"Task: {task}\n\nTwo strong partial plans:\n\n" + + "\n\n".join(f"Plan {n + 1}:\n{approaches[i]}" for n, i in enumerate(kept)) + + "\n\nAggregate them into ONE plan that keeps each one's " + "strengths and drops the weaknesses. Reply as a single JSON string.", + parse=parse_text, + ) + vertices["agg"] = merged + for i in kept: + edges.append((f"g{i}", "aggregate", "agg")) + + # refine: one self-loop improvement pass on the aggregate. + refined = await generator.ask( + f"Task: {task}\n\nCurrent plan:\n{merged}\n\nRefine it: fix " + "gaps, tighten ordering, remove redundancy. Reply as a single " + "JSON string.", + parse=parse_text, + ) + vertices["ref"] = refined + edges.append(("agg", "refine", "ref")) + + # final score: did the graph improve on its best branch? + final_scores = await scorer.ask( + f"Task: {task}\n\nThought A:\n{approaches[kept[0]]}\n\n" + f"Thought B:\n{refined}\n\nScore each 0-10. Reply as JSON: " + '{"scores": [<2 numbers>]}', + parse=parse_scores(2), + ) + await c.note( + f"graph-of-thoughts: best branch {final_scores[0]:.1f} vs " + f"aggregated+refined {final_scores[1]:.1f}" + ) + print("\nexecuted DAG:") + for src, op, dst in edges: + print(f" {src} --{op}--> {dst}") + print(f"\nfinal plan (scored {final_scores[1]:.1f}/10 vs best branch " + f"{final_scores[0]:.1f}/10):\n{refined}") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/lats.py b/examples/papers/lats.py new file mode 100644 index 0000000..b199a81 --- /dev/null +++ b/examples/papers/lats.py @@ -0,0 +1,160 @@ +"""Language Agent Tree Search Unifies Reasoning, Acting, and Planning in +Language Models (Zhou et al. 2024, arXiv:2310.04406) — MCTS over real +attempts, with environment reward and reflections. + +LATS runs Monte Carlo tree search where a node is an actual attempt, not a +hypothetical thought: expansion is a revise turn, the reward blends real +environment feedback (here ``conductor.verify``) with an LLM value +estimate, values back-propagate to the root, and failed nodes leave +Reflexion-style reflections that steer later expansions. UCT selection, +expansion, evaluation, and backprop are ~30 lines of plain Python; every +turn in the tree is journaled, so an interrupted search resumes mid-tree. + + python examples/papers/lats.py [""] # default: implement quicksort with pytest +""" + +import asyncio +import math +import sys +from dataclasses import dataclass, field +from typing import Any + +from h5i.orchestra import Artifact, Conductor, Review, Verification + +DEMO_TASK = "implement quicksort with pytest" +VERIFY = ["pytest", "-q"] +MAX_EXPANSIONS = 3 +EXPLORATION = 1.0 # UCT exploration constant + + +@dataclass +class Node: + artifact: Artifact + parent: "Node | None" = None + children: list["Node"] = field(default_factory=list) + visits: int = 0 + total_value: float = 0.0 + reflection: str = "" + green: bool = False + + +def describe(v: Verification) -> str: + line = ( + f"`{' '.join(v.command)}`: " + + ("applied cleanly" if v.applies_cleanly else "failed to apply") + + ", " + + ("tests passed" if v.tests_passed else "tests FAILED") + ) + if v.failure: + line += f"\nfailure: {v.failure}" + return line + + +def parse_value(value: Any) -> float: + score = value.get("score") if isinstance(value, dict) else value + return max(0.0, min(10.0, float(score))) + + +def uct(node: Node, parent_visits: int) -> float: + if node.visits == 0: + return float("inf") + exploit = node.total_value / node.visits + return exploit + EXPLORATION * math.sqrt(math.log(parent_visits) / node.visits) + + +def select(root: Node) -> Node: + node = root + while node.children: + node = max(node.children, key=lambda ch: uct(ch, max(1, node.visits))) + return node + + +def backpropagate(node: Node, value: float) -> None: + cursor: Node | None = node + while cursor is not None: + cursor.visits += 1 + cursor.total_value += value + cursor = cursor.parent + + +def lineage_reflections(node: Node) -> list[str]: + out: list[str] = [] + cursor: Node | None = node + while cursor is not None: + if cursor.reflection: + out.append(cursor.reflection) + cursor = cursor.parent + return list(reversed(out)) + + +async def main(task: str) -> None: + async with Conductor(".", "lats-demo", launcher="resident", isolation="supervised") as c: + solver = await c.hire("solver", runtime="claude", model="claude-haiku-4-5") + valuer = await c.hire("valuer", runtime="claude", model="claude-haiku-4-5") + + async def evaluate(node: Node) -> float: + """Reward = real environment feedback + an LLM value estimate.""" + verification = await c.verify(node.artifact, VERIFY) + if verification.applies_cleanly and verification.tests_passed: + node.green = True + return 10.0 + node.reflection = await solver.ask( + "Your attempt failed neutral verification.\n" + f"{describe(verification)}\n\nIn 1-2 sentences: what went " + "wrong, and what should the NEXT attempt do differently? " + "Reply as a single JSON string.", + parse=lambda v: v if isinstance(v, str) else str(v), + ) + estimate = await valuer.ask( + f"Task: {task}\nAn attempt failed verification with:\n" + f"{describe(verification)}\nIts author reflected: " + f"{node.reflection}\nHow close is this attempt to a working " + 'solution? Reply as JSON: {"score": <0-10>}', + parse=parse_value, + ) + return estimate + + # Root: one real attempt, then seal the round. + root = Node(await solver.work(task, expect_independent=True)) + await c.freeze() + backpropagate(root, await evaluate(root)) + + expansions = 0 + best = root + while not best.green and expansions < MAX_EXPANSIONS: + leaf = select(root) + lessons = lineage_reflections(leaf) + child = Node( + await solver.revise( + leaf.artifact, + Review( + reviewer="lats-search", + target=solver.id, + round=leaf.artifact.round, + body=( + "Verdict: REVISE\n\nReflections along this " + "branch of the search tree:\n" + + "\n".join(f"- {r}" for r in lessons) + + "\n\nProduce an improved attempt." + ), + referenced_artifacts=(leaf.artifact.id,), + ), + ), + parent=leaf, + ) + leaf.children.append(child) + expansions += 1 + value = await evaluate(child) + backpropagate(child, value) + print(f"expansion {expansions}: value {value:.1f}" + + (" (green)" if child.green else "")) + if child.green or value > best.total_value / max(1, best.visits): + best = child + + verdict = await c.judge() + print(f"\nexplored {expansions} expansion(s); best node green={best.green}") + print("verdict:", verdict.selected_submission or "none", "—", *verdict.reasons) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/least_to_most.py b/examples/papers/least_to_most.py new file mode 100644 index 0000000..905de0a --- /dev/null +++ b/examples/papers/least_to_most.py @@ -0,0 +1,78 @@ +"""Least-to-Most Prompting Enables Complex Reasoning in Large Language +Models (Zhou et al. 2023, arXiv:2205.10625) — decompose, then solve +easiest-first with accumulated answers. + +Two stages: a decomposer reduces the problem to a sequence of simpler +subquestions ordered least-to-most difficult, then a solver answers them +strictly in order, with every prior subquestion *and its answer* riding in +the prompt — so each step only ever bridges a small gap. The sequential +dependency chain is a plain ``for`` loop over journaled ``ask`` turns. + + python examples/papers/least_to_most.py [""] +""" + +import asyncio +import sys +from typing import Any + +from h5i.orchestra import Conductor + +DEMO_QUESTION = ( + "Elsa has 5 apples. Anna has 2 more apples than Elsa. Together with " + "Kristoff's apples the three have 14. How many apples does Kristoff have?" +) +MAX_SUBQUESTIONS = 4 + + +def parse_subquestions(value: Any) -> list[str]: + if not isinstance(value, list) or not value: + raise ValueError("reply must be a non-empty JSON array of subquestion strings") + return [str(q).strip() for q in value][:MAX_SUBQUESTIONS] + + +def parse_text(value: Any) -> str: + return value if isinstance(value, str) else str(value) + + +async def main(question: str) -> None: + async with Conductor(".", "l2m-demo", launcher="resident", isolation="supervised") as c: + decomposer = await c.hire("decomposer", runtime="claude", model="claude-haiku-4-5") + solver = await c.hire("solver", runtime="claude", model="claude-haiku-4-5") + + # Stage 1: decompose, easiest first. + subquestions = await decomposer.ask( + f"Problem: {question}\n\nDecompose it into at most " + f"{MAX_SUBQUESTIONS} subquestions ordered from easiest to " + "hardest, where each builds on the previous answers and the " + "last one resolves the original problem. Reply as a JSON array " + "of strings.", + parse=parse_subquestions, + ) + + # Stage 2: solve in order; every prior Q/A rides along. + solved: list[tuple[str, str]] = [] + for i, sub in enumerate(subquestions, 1): + context = "".join(f"Q: {q}\nA: {a}\n" for q, a in solved) + answer = await solver.ask( + f"Original problem: {question}\n\n" + + (f"Already solved:\n{context}\n" if solved else "") + + f"Next subquestion: {sub}\n\nAnswer just this subquestion. " + "Reply as a single JSON string.", + parse=parse_text, + ) + solved.append((sub, answer)) + print(f"{i}. {sub}\n → {answer}") + + final = await solver.ask( + f"Original problem: {question}\n\nSolved subquestions:\n" + + "".join(f"Q: {q}\nA: {a}\n" for q, a in solved) + + "\nState the final answer to the original problem, as short " + "as possible. Reply as a single JSON string.", + parse=parse_text, + ) + await c.note(f"least-to-most: {len(solved)} subquestions → {final}") + print(f"\nfinal answer: {final}") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_QUESTION)) diff --git a/examples/papers/llm_blender.py b/examples/papers/llm_blender.py new file mode 100644 index 0000000..8ee62aa --- /dev/null +++ b/examples/papers/llm_blender.py @@ -0,0 +1,102 @@ +"""LLM-Blender: Ensembling Large Language Models with Pairwise Ranking and +Generative Fusion (Jiang et al. 2023, arXiv:2306.02561) — rank pairwise, +then fuse the top-k. + +Two stages: a PairRanker compares candidates two at a time (pairwise +comparison is far more reliable than absolute scoring), and a GenFuser +generates a *new* answer from the top-ranked candidates instead of just +picking one. Here the candidate pool is model-diverse seats, the ranker +sees every unordered pair in BOTH orders — a pair only counts as a win if +the same candidate wins both presentations, washing out position bias — +and the fuser gets the top-k by win count. + + python examples/papers/llm_blender.py [""] +""" + +import asyncio +import sys +from itertools import combinations +from typing import Any, Mapping + +from h5i.orchestra import Conductor + +DEMO_TASK = ( + "Explain to a junior engineer when to prefer optimistic locking over " + "pessimistic locking, with one concrete example of each." +) +TOP_K = 2 + + +def parse_text(value: Any) -> str: + return value if isinstance(value, str) else str(value) + + +def parse_winner(value: Any) -> str: + label = value.get("winner") if isinstance(value, Mapping) else value + if not isinstance(label, str) or label.strip().upper() not in {"A", "B"}: + raise ValueError('reply must be {"winner": "A"} or {"winner": "B"}') + return label.strip().upper() + + +async def main(task: str) -> None: + async with Conductor(".", "llm-blender-demo", launcher="resident", isolation="supervised") as c: + pool = [ + await c.hire("gen0", runtime="claude", model="claude-haiku-4-5"), + await c.hire("gen1", runtime="codex", model="gpt-5.4-mini", effort="medium"), + await c.hire("gen2", runtime="claude", model="claude-haiku-4-5"), + ] + ranker = await c.hire("ranker", runtime="claude", model="claude-haiku-4-5") + fuser = await c.hire("fuser", runtime="claude", model="claude-haiku-4-5") + + # Candidate pool: N independent generations. + candidates = list( + await asyncio.gather( + *( + seat.ask(f"{task}\n\nReply as a single JSON string.", parse=parse_text) + for seat in pool + ) + ) + ) + + # PairRanker: every unordered pair, presented in both orders; a win + # counts only if it survives the position swap. + wins = [0.0] * len(candidates) + for i, j in combinations(range(len(candidates)), 2): + verdicts = [] + for first, second in ((i, j), (j, i)): + label = await ranker.ask( + f"Instruction: {task}\n\n" + f"Candidate A:\n{candidates[first]}\n\n" + f"Candidate B:\n{candidates[second]}\n\n" + "Which candidate answers the instruction better? Reply as " + 'JSON: {"winner": "A"} or {"winner": "B"}', + parse=parse_winner, + ) + verdicts.append(first if label == "A" else second) + if verdicts[0] == verdicts[1]: + wins[verdicts[0]] += 1.0 + else: # position-dependent — a tie, half a win each + wins[i] += 0.5 + wins[j] += 0.5 + + ranking = sorted(range(len(candidates)), key=lambda k: -wins[k]) + for rank, k in enumerate(ranking, 1): + print(f"#{rank} {pool[k].id} ({wins[k]:.1f} pairwise wins)") + + # GenFuser: generate a better answer FROM the top-k, don't just pick. + top = "\n\n".join( + f"Candidate {rank}:\n{candidates[k]}" + for rank, k in enumerate(ranking[:TOP_K], 1) + ) + fused = await fuser.ask( + f"Instruction: {task}\n\nThe top-ranked candidate answers:\n\n{top}\n\n" + "Fuse them: keep each one's strengths, drop the weaknesses, and " + "produce a single better answer. Reply as a single JSON string.", + parse=parse_text, + ) + await c.note(f"llm-blender: fused top-{TOP_K} of {len(candidates)} candidates") + print(f"\nfused answer:\n{fused}") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/mad_divergent.py b/examples/papers/mad_divergent.py new file mode 100644 index 0000000..a1171ef --- /dev/null +++ b/examples/papers/mad_divergent.py @@ -0,0 +1,105 @@ +"""Encouraging Divergent Thinking in Large Language Models through +Multi-Agent Debate (Liang et al. 2024, arXiv:2305.19118) — the MAD +tit-for-tat: affirmative vs. negative under an adaptive judge. + +Self-reflection gets stuck in its own frame ("degeneration of thought"); +MAD forces divergence by making one side *obligated to disagree*. An +affirmative seat proposes, a negative seat must rebut, and after every +exchange a judge decides whether the debate has produced a defensible +answer — stopping adaptively rather than after a fixed budget. Different +runtimes on the two sides sharpen the disagreement. + + python examples/papers/mad_divergent.py [""] +""" + +import asyncio +import sys +from typing import Any, Mapping + +from h5i.orchestra import Conductor + +DEMO_QUESTION = ( + "When Alice was 6, her sister was half her age. Alice is now 70. " + "How old is her sister?" +) +MAX_ROUNDS = 3 + + +def parse_text(value: Any) -> str: + return value if isinstance(value, str) else str(value) + + +def parse_ruling(value: Any) -> dict[str, Any]: + if not isinstance(value, Mapping) or "resolved" not in value: + raise ValueError( + 'reply must be {"resolved": true|false, "answer": "...", "rationale": "..."}' + ) + return { + "resolved": bool(value["resolved"]), + "answer": str(value.get("answer", "")).strip(), + "rationale": str(value.get("rationale", "")).strip(), + } + + +def rendered(transcript: list[tuple[str, str]]) -> str: + return "".join(f"[{who}] {what}\n" for who, what in transcript) + + +async def main(question: str) -> None: + async with Conductor(".", "mad-demo", launcher="resident", isolation="supervised") as c: + affirmative = await c.hire("affirmative", runtime="claude", model="claude-haiku-4-5") + negative = await c.hire("negative", runtime="codex", model="gpt-5.4-mini", effort="medium") + judge = await c.hire("judge", runtime="claude", model="claude-haiku-4-5") + + transcript: list[tuple[str, str]] = [] + opening = await affirmative.ask( + f"Question: {question}\n\nGive your answer and your strongest " + "supporting argument. Reply as a single JSON string.", + parse=parse_text, + ) + transcript.append((affirmative.id, opening)) + + ruling: dict[str, Any] = {"resolved": False, "answer": "", "rationale": ""} + for round_no in range(1, MAX_ROUNDS + 1): + # The negative side is OBLIGATED to disagree — that is the + # divergence mechanism. + rebuttal = await negative.ask( + f"Question: {question}\n\nDebate so far:\n{rendered(transcript)}\n" + "You are the negative side: you MUST disagree with the " + "affirmative's latest position. Find the flaw, or the " + "strongest alternative answer, and argue it. Reply as a " + "single JSON string.", + parse=parse_text, + ) + transcript.append((negative.id, rebuttal)) + + # Adaptive stop: the judge rules after every exchange. + ruling = await judge.ask( + f"You judge this debate.\nQuestion: {question}\n\n" + f"Transcript:\n{rendered(transcript)}\n" + "Has a defensible final answer emerged? Reply as JSON: " + '{"resolved": true|false, "answer": "", "rationale": ""}', + parse=parse_ruling, + ) + print(f"round {round_no}: resolved={ruling['resolved']}") + if ruling["resolved"] or round_no == MAX_ROUNDS: + break + + defense = await affirmative.ask( + f"Question: {question}\n\nDebate so far:\n{rendered(transcript)}\n" + "Respond to the negative side: defend, repair, or — if it is " + "genuinely right — concede and adopt the corrected answer. " + "Reply as a single JSON string.", + parse=parse_text, + ) + transcript.append((affirmative.id, defense)) + + for who, what in transcript: + print(f"[{who}] {what[:110]}…") + await c.note(f"MAD verdict: {ruling['answer']} — {ruling['rationale']}") + print(f"\njudge's answer: {ruling['answer']}\nrationale: {ruling['rationale']}") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_QUESTION)) diff --git a/examples/papers/mapcoder.py b/examples/papers/mapcoder.py new file mode 100644 index 0000000..f1152e4 --- /dev/null +++ b/examples/papers/mapcoder.py @@ -0,0 +1,137 @@ +"""MapCoder: Multi-Agent Code Generation for Competitive Problem Solving +(Islam et al. 2024, arXiv:2405.11403) — retrieval → planning → coding → +plan-wise debugging. + +Four stages mirror the human contest workflow. The retrieval agent +*self-generates* exemplars (similar problems it knows, each with a plan) — +no external database. The planner turns exemplars into several candidate +plans, each with a confidence score. The coder attempts plans in confidence +order; each attempt is neutrally executed, and failures get a bounded +debugging loop *within the current plan* before falling back to the next +one — MapCoder's key move: don't debug a doomed plan forever, switch plans. + + python examples/papers/mapcoder.py [""] # default: implement quicksort with pytest +""" + +import asyncio +import json +import sys +from typing import Any, Mapping + +from h5i.orchestra import Conductor, Review, Verification + +DEMO_TASK = "implement quicksort with pytest" +VERIFY = ["pytest", "-q"] +K_EXEMPLARS = 2 +K_PLANS = 2 +MAX_DEBUG = 2 # debugging attempts per plan + + +def parse_exemplars(value: Any) -> list[dict[str, str]]: + items = value.get("exemplars") if isinstance(value, Mapping) else None + if not isinstance(items, list) or not items: + raise ValueError( + 'reply must be {"exemplars": [{"problem": "...", "plan": "..."}]}' + ) + return [ + {"problem": str(e.get("problem", "")), "plan": str(e.get("plan", ""))} + for e in items + ][:K_EXEMPLARS] + + +def parse_plans(value: Any) -> list[dict[str, Any]]: + items = value.get("plans") if isinstance(value, Mapping) else None + if not isinstance(items, list) or not items: + raise ValueError( + 'reply must be {"plans": [{"plan": "...", "confidence": <0-100>}]}' + ) + plans = [ + {"plan": str(p.get("plan", "")), "confidence": float(p.get("confidence", 0))} + for p in items + ][:K_PLANS] + return sorted(plans, key=lambda p: -p["confidence"]) + + +def describe(v: Verification) -> str: + line = ( + f"`{' '.join(v.command)}`: " + + ("applied cleanly" if v.applies_cleanly else "failed to apply") + + ", " + + ("tests passed" if v.tests_passed else "tests FAILED") + ) + if v.failure: + line += f"\nfailure: {v.failure}" + return line + + +async def main(task: str) -> None: + async with Conductor(".", "mapcoder-demo", launcher="resident", isolation="supervised") as c: + retrieval = await c.hire("retrieval", runtime="claude", model="claude-haiku-4-5") + planner = await c.hire("planner", runtime="claude", model="claude-haiku-4-5") + coder = await c.hire("coder", runtime="codex", model="gpt-5.4-mini", effort="medium") + + # 1. Retrieval: self-generated exemplars, no external database. + exemplars = await retrieval.ask( + f"Recall {K_EXEMPLARS} problems you know that are algorithmically " + f"similar to: {task}\nFor each, state the problem and a " + "step-by-step solution plan. Reply as JSON: " + '{"exemplars": [{"problem": "...", "plan": "..."}]}', + parse=parse_exemplars, + ) + + # 2. Planning: candidate plans, ranked by the planner's confidence. + plans = await planner.ask( + "Similar solved problems:\n" + + json.dumps(exemplars, indent=2) + + f"\n\nUsing them as guidance, write {K_PLANS} DISTINCT " + f"step-by-step plans for: {task}\nRate each plan's confidence " + '0-100. Reply as JSON: {"plans": [{"plan": "...", ' + '"confidence": <0-100>}]}', + parse=parse_plans, + ) + + # 3-4. Coding + plan-wise debugging: attempt plans in confidence + # order; debug within a plan only a bounded number of times. + frozen = False + green = False + for rank, plan in enumerate(plans, 1): + print(f"plan {rank} (confidence {plan['confidence']:.0f})") + attempt = await coder.work( + f"{task}\n\nFollow this plan strictly:\n{plan['plan']}", + expect_independent=not frozen, + ) + if not frozen: + await c.freeze() + frozen = True + for debug in range(MAX_DEBUG + 1): + verification = await c.verify(attempt, VERIFY) + if verification.applies_cleanly and verification.tests_passed: + green = True + print(f" green after {debug} debug turn(s)") + break + if debug == MAX_DEBUG: + print(f" plan {rank} exhausted its debug budget — switching plans") + break + attempt = await coder.revise( + attempt, + Review( + reviewer="plan-debugger", + target=coder.id, + round=attempt.round, + body=( + "Verdict: REVISE\n\nStay on the current plan:\n" + f"{plan['plan']}\n\nNeutral execution failed:\n" + f"{describe(verification)}" + ), + referenced_artifacts=(attempt.id,), + ), + ) + if green: + break + + verdict = await c.judge() + print("verdict:", verdict.selected_submission or "none", "—", *verdict.reasons) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/mav_bon.py b/examples/papers/mav_bon.py new file mode 100644 index 0000000..26ab990 --- /dev/null +++ b/examples/papers/mav_bon.py @@ -0,0 +1,124 @@ +"""Multi-Agent Verification: Scaling Test-Time Compute with Multiple +Verifiers (Lifshitz et al. 2025, arXiv:2502.20379) — BoN-MAV: best-of-n +candidates × m aspect verifiers. + +Instead of one reward model, MAV scales the *number of verifiers*: many +simple aspect verifiers each give a binary pass/fail on one dimension, and +the candidate with the most approvals wins. The h5i mapping: n independent +work attempts are the best-of-n pool; each candidate is neutrally executed +first (``conductor.verify``) so the aspect verifiers ground their binary +votes in recorded evidence, not vibes; approval counting and the tie-break +(smaller diff) are an ordinary verdict policy. Add aspects to scale m. + + python examples/papers/mav_bon.py [""] # default: implement quicksort with pytest +""" + +import asyncio +import sys +from typing import Any, Mapping + +from h5i.orchestra import Conductor, Verdict, patterns + +DEMO_TASK = "implement quicksort with pytest" +VERIFY = ["pytest", "-q"] +ASPECTS = ( + ("verifier-functional", "functional correctness: does the evidence show it works?"), + ("verifier-edgecases", "edge-case coverage: empty, single-element, duplicate, adversarial inputs"), + ("verifier-simplicity", "simplicity: smallest change that honestly does the job"), +) + + +def parse_approvals(candidate_ids: list[str]): + def parse(value: Any) -> dict[str, bool]: + if not isinstance(value, Mapping) or not isinstance(value.get("approvals"), list): + raise ValueError( + 'reply must be {"approvals": [{"artifact_id": "...", "pass": true|false, "reason": "..."}]}' + ) + votes: dict[str, bool] = {} + for entry in value["approvals"]: + if not isinstance(entry, Mapping) or entry.get("artifact_id") not in candidate_ids: + raise ValueError(f"approvals must cover only the candidates {candidate_ids}") + votes[str(entry["artifact_id"])] = bool(entry.get("pass", False)) + missing = [cid for cid in candidate_ids if cid not in votes] + if missing: + raise ValueError(f"missing approvals for {missing}") + return votes + + return parse + + +async def main(task: str) -> None: + async with Conductor(".", "mav-demo", launcher="resident", isolation="supervised") as c: + pool = [ + await c.hire("cand0", runtime="claude", model="claude-haiku-4-5"), + await c.hire("cand1", runtime="claude", model="claude-haiku-4-5"), + await c.hire("cand2", runtime="codex", model="gpt-5.4-mini", effort="medium"), + ] + verifiers = [ + await c.hire(name, runtime="claude", model="claude-haiku-4-5") + for name, _ in ASPECTS + ] + + # Best-of-n: independent candidates, sealed, then neutrally executed + # so every aspect vote is grounded in recorded evidence. + candidates = list( + await asyncio.gather(*(a.work(task, expect_independent=True) for a in pool)) + ) + await c.freeze() + for artifact in candidates: + await c.verify(artifact, VERIFY) + + status = await c.status() + candidate_ids = [s.id for s in status.submissions] + evidence = patterns.render_evidence(status) + + # m aspect verifiers, each a binary vote per candidate, in parallel. + cards = await asyncio.gather( + *( + verifier.ask( + f"You verify ONE aspect only: {aspect}\n" + f"Task: {task}\n\nRecorded evidence:\n{evidence}\n" + "For EACH candidate give a binary pass/fail on your aspect " + "alone. Reply as JSON: " + '{"approvals": [{"artifact_id": "", "pass": true|false, ' + '"reason": ""}]}', + parse=parse_approvals(candidate_ids), + ) + for verifier, (_, aspect) in zip(verifiers, ASPECTS) + ) + ) + approvals = { + cid: sum(1 for card in cards if card.get(cid)) for cid in candidate_ids + } + for cid in candidate_ids: + print(f"{cid}: {approvals[cid]}/{len(ASPECTS)} aspect approvals") + + # The BoN-MAV selection rule as a verdict policy: most approvals + # wins; ties go to the smaller diff. + def most_approvals(run) -> Verdict: + ranked = sorted( + run.submissions, + key=lambda s: ( + -approvals.get(s.id, 0), + s.files_changed, + s.insertions, + s.id, + ), + ) + winner = ranked[0] + return Verdict( + method=f"mav:bon({len(ASPECTS)} aspect verifiers)", + decided_by="mav-demo score", + selected_submission=winner.id, + can_auto_apply=False, + reasons=( + f"{winner.id} won {approvals.get(winner.id, 0)}/{len(ASPECTS)} approvals", + ), + ) + + verdict = await c.judge(most_approvals) + print("\nverdict:", verdict.selected_submission, "—", *verdict.reasons) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/meta_prompting.py b/examples/papers/meta_prompting.py new file mode 100644 index 0000000..d349da3 --- /dev/null +++ b/examples/papers/meta_prompting.py @@ -0,0 +1,101 @@ +"""Meta-Prompting: Enhancing Language Models with Task-Agnostic Scaffolding +(Suzgun & Kalai 2024, arXiv:2401.12954) — a conductor invents and consults +fresh experts. + +One model wears two hats: as the *meta* model it breaks the problem down, +decides which expert would help next, writes that expert's persona and +instructions from scratch, and integrates the replies; each *expert* is a +fresh instance that sees only the excerpt the conductor chose to pass — no +history, no task statement. Here the conductor is one seat and experts +draw from a small pool of clean seats (rotated per consultation), so the +paper's fresh-eyes property holds while sessions stay resident. + + python examples/papers/meta_prompting.py [""] +""" + +import asyncio +import sys +from typing import Any, Mapping + +from h5i.orchestra import Conductor + +DEMO_TASK = ( + "Write a correct regular expression that matches valid IPv4 addresses " + "(reject 999.1.1.1 and leading zeros like 01.2.3.4), and explain it." +) +MAX_CONSULTATIONS = 4 + + +def parse_move(value: Any) -> dict[str, str]: + if not isinstance(value, Mapping) or "action" not in value: + raise ValueError( + 'reply must be {"action": "consult"|"final", "persona": "...", ' + '"instructions": "...", "final_answer": "..."}' + ) + action = str(value["action"]).strip().lower() + if action not in {"consult", "final"}: + raise ValueError('action must be "consult" or "final"') + return { + "action": action, + "persona": str(value.get("persona", "")).strip(), + "instructions": str(value.get("instructions", "")).strip(), + "final_answer": str(value.get("final_answer", "")).strip(), + } + + +def parse_text(value: Any) -> str: + return value if isinstance(value, str) else str(value) + + +async def main(task: str) -> None: + async with Conductor(".", "metaprompt-demo", launcher="resident", isolation="supervised") as c: + conductor_seat = await c.hire("conductor", runtime="claude", model="claude-haiku-4-5") + expert_pool = [ + await c.hire(f"expert{i}", runtime="claude", model="claude-haiku-4-5") + for i in range(2) + ] + + history = "" + final = "" + for consult_no in range(1, MAX_CONSULTATIONS + 1): + move = await conductor_seat.ask( + f"You are the meta model orchestrating experts.\nTask: {task}\n\n" + f"Consultations so far:\n{history or '(none)'}\n\n" + "Either consult one more expert — invent the most useful " + "persona and write its instructions, INCLUDING every detail " + "it needs (experts see nothing but your instructions) — or, " + "if you can already answer, finish. Reply as JSON: " + '{"action": "consult"|"final", "persona": "...", ' + '"instructions": "...", "final_answer": "..."}', + parse=parse_move, + ) + if move["action"] == "final": + final = move["final_answer"] + print(f"consultation {consult_no}: conductor finishes") + break + + # A fresh expert: sees only the conductor's instructions. + expert = expert_pool[(consult_no - 1) % len(expert_pool)] + reply = await expert.ask( + f"You are: {move['persona']}\n\n{move['instructions']}\n\n" + "Reply as a single JSON string.", + parse=parse_text, + ) + history += ( + f"--- consultation {consult_no}: {move['persona']} ---\n" + f"instructions: {move['instructions']}\nreply: {reply}\n\n" + ) + print(f"consultation {consult_no}: {move['persona']}") + + if not final: # budget spent — force the conductor to conclude + final = await conductor_seat.ask( + f"Task: {task}\n\nConsultations:\n{history}\nGive your final " + "answer now. Reply as a single JSON string.", + parse=parse_text, + ) + await c.note("meta-prompting: finished") + print(f"\nfinal answer:\n{final}") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/metagpt.py b/examples/papers/metagpt.py new file mode 100644 index 0000000..3b64d65 --- /dev/null +++ b/examples/papers/metagpt.py @@ -0,0 +1,105 @@ +"""MetaGPT: Meta Programming for a Multi-Agent Collaborative Framework +(Hong et al. 2024, arXiv:2308.00352) — an SOP where roles exchange +structured documents, not chat. + +MetaGPT's core claim: cross-role communication should be *standardized +artifacts* (PRD, design doc, code, QA report), because free-form chat +compounds hallucination across hand-offs. Here the PRD and design are +validated-JSON ``ask`` turns; the engineer implements against the documents +alone; QA is both a real execution (``conductor.verify``) and a review +turn, merged into one revision — ``patterns.merge_reviews`` playing the +"QA report" document. + + python examples/papers/metagpt.py [""] # default: implement quicksort with pytest +""" + +import asyncio +import json +import sys +from typing import Any, Mapping + +from h5i.orchestra import Conductor, Review, patterns + +DEMO_TASK = "implement quicksort with pytest" +VERIFY = ["pytest", "-q"] + + +def parse_doc(*required: str): + def parse(value: Any) -> dict[str, Any]: + if not isinstance(value, Mapping): + raise ValueError("reply must be a JSON object") + missing = [k for k in required if k not in value] + if missing: + raise ValueError(f"document is missing required sections: {missing}") + return dict(value) + + return parse + + +async def main(task: str) -> None: + async with Conductor(".", "metagpt-demo", launcher="resident", isolation="supervised") as c: + product_manager = await c.hire("pm", runtime="claude", model="claude-haiku-4-5") + architect = await c.hire("architect", runtime="claude", model="claude-haiku-4-5") + engineer = await c.hire("engineer", runtime="codex", model="gpt-5.4-mini", effort="medium") + qa = await c.hire("qa", runtime="claude", model="claude-haiku-4-5") + + # SOP document 1 — the PRD, a structured artifact, not chat. + prd = await product_manager.ask( + f"You are the product manager. Write the PRD for: {task}\n" + 'Reply as JSON: {"goals": ["..."], "user_stories": ["..."], ' + '"acceptance_criteria": ["..."]}', + parse=parse_doc("goals", "user_stories", "acceptance_criteria"), + ) + + # SOP document 2 — the design, grounded in the PRD. + design = await architect.ask( + "You are the architect. PRD:\n" + + json.dumps(prd, indent=2) + + '\n\nProduce the design. Reply as JSON: {"modules": ' + '[{"name": "...", "responsibility": "..."}], "interfaces": ["..."]}', + parse=parse_doc("modules", "interfaces"), + ) + + # Implementation against the documents alone. + artifact = await engineer.work( + f"You are the engineer. Implement exactly this specification.\n" + f"Task: {task}\n\nPRD:\n{json.dumps(prd, indent=2)}\n\n" + f"Design:\n{json.dumps(design, indent=2)}", + expect_independent=True, + ) + await c.freeze() + + # QA: real execution + a review turn, folded into one QA report. + verification = await c.verify(artifact, VERIFY) + review = await qa.review(artifact) + if not (verification.applies_cleanly and verification.tests_passed and review.approved): + qa_report = patterns.merge_reviews( + [ + review, + Review( + reviewer="qa-verification", + target=engineer.id, + round=artifact.round, + body=( + "Verdict: REVISE\n\nNeutral execution of " + f"`{' '.join(verification.command)}`: " + + ("passed" if verification.tests_passed else "FAILED") + + (f"\nfailure: {verification.failure}" if verification.failure else "") + + "\n\nAcceptance criteria:\n" + + "\n".join(f"- {a}" for a in prd["acceptance_criteria"]) + ), + ), + ], + artifact, + ) + artifact = await engineer.revise(artifact, qa_report) + await c.verify(artifact, VERIFY) + + verdict = await c.judge() + print("PRD goals:", "; ".join(str(g) for g in prd["goals"])) + print("modules:", "; ".join(m.get("name", "?") for m in design["modules"])) + print("verdict:", verdict.selected_submission or "none", "—", *verdict.reasons) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/mixture_of_agents.py b/examples/papers/mixture_of_agents.py new file mode 100644 index 0000000..cb1b486 --- /dev/null +++ b/examples/papers/mixture_of_agents.py @@ -0,0 +1,87 @@ +"""Mixture-of-Agents Enhances Large Language Model Capabilities (Wang et +al. 2024, arXiv:2406.04692) — layered proposers feeding an aggregator. + +MoA's finding ("collaborativeness"): an LLM produces a better answer when +shown other models' attempts, even attempts from weaker models. So: layer 1 +proposers answer independently; each layer-l proposer receives ALL of layer +l-1's answers as auxiliary references and synthesizes an improved one; a +final aggregator fuses the last layer. The same model-diverse seats serve +every proposer layer (the paper reuses models across layers); depth and +width are the two scaling knobs. + + python examples/papers/mixture_of_agents.py [""] +""" + +import asyncio +import sys +from typing import Any + +from h5i.orchestra import Conductor + +DEMO_TASK = ( + "Write a concise design note: how should a small CLI tool store user " + "credentials safely, and what trade-offs matter?" +) +LAYERS = 2 # proposer layers before the final aggregation + + +def parse_text(value: Any) -> str: + return value if isinstance(value, str) else str(value) + + +def referenced(responses: list[str]) -> str: + return "\n\n".join(f"Reference {i + 1}:\n{r}" for i, r in enumerate(responses)) + + +async def main(task: str) -> None: + async with Conductor(".", "moa-demo", launcher="resident", isolation="supervised") as c: + proposers = [ + await c.hire("prop0", runtime="claude", model="claude-haiku-4-5"), + await c.hire("prop1", runtime="codex", model="gpt-5.4-mini", effort="medium"), + await c.hire("prop2", runtime="claude", model="claude-haiku-4-5"), + ] + aggregator = await c.hire("aggregator", runtime="claude", model="claude-haiku-4-5") + + # Layer 1: independent proposals. + responses = list( + await asyncio.gather( + *( + p.ask(f"{task}\n\nReply as a single JSON string.", parse=parse_text) + for p in proposers + ) + ) + ) + + # Layers 2..L: every proposer sees ALL previous-layer responses. + for layer in range(2, LAYERS + 1): + responses = list( + await asyncio.gather( + *( + p.ask( + f"{task}\n\nResponses from the previous layer of " + f"agents:\n\n{referenced(responses)}\n\n" + "Use them as auxiliary references — adopt what is " + "right, correct what is wrong — and write your own " + "improved response. Reply as a single JSON string.", + parse=parse_text, + ) + for p in proposers + ) + ) + ) + print(f"layer {layer}: {len(responses)} refined proposals") + + # Final aggregation. + final = await aggregator.ask( + f"{task}\n\nCandidate responses from a mixture of agents:\n\n" + f"{referenced(responses)}\n\n" + "Aggregate them into the single best response: synthesize, do not " + "merely select. Reply as a single JSON string.", + parse=parse_text, + ) + await c.note(f"mixture-of-agents: {LAYERS} layers × {len(proposers)} proposers") + print(f"\naggregated answer:\n{final}") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/multiagent_debate.py b/examples/papers/multiagent_debate.py new file mode 100644 index 0000000..d06dc5f --- /dev/null +++ b/examples/papers/multiagent_debate.py @@ -0,0 +1,95 @@ +"""Improving Factuality and Reasoning in Language Models through Multiagent +Debate (Du et al. 2023, arXiv:2305.14325) — the "society of minds" loop. + +Each agent answers independently; then, for a fixed number of rounds, every +agent reads the *other* agents' latest answers and reasoning and updates its +own. Answers provably converge or expose genuine disagreement; the final +call is a majority vote. Pure ``ask`` data turns — each round is one +parallel gather across distinct seats, and the debate ends early once +everyone agrees. + + python examples/papers/multiagent_debate.py [""] +""" + +import asyncio +import sys +from collections import Counter +from typing import Any, Mapping + +from h5i.orchestra import Conductor + +DEMO_QUESTION = ( + "Three people check into a hotel room that costs $30 and pay $10 each. " + "The clerk realizes the room costs $25 and hands the bellboy $5 to " + "return; the bellboy keeps $2 and gives $1 back to each guest. Each " + "guest paid $9 (totalling $27) and the bellboy has $2 — where is the " + "missing dollar?" +) +N_DEBATERS = 3 +ROUNDS = 2 + + +def parse_position(value: Any) -> dict[str, str]: + if not isinstance(value, Mapping) or "answer" not in value: + raise ValueError('reply must be {"reasoning": "...", "answer": "..."}') + return { + "answer": str(value["answer"]).strip(), + "reasoning": str(value.get("reasoning", "")).strip(), + } + + +def canon(answer: str) -> str: + return answer.strip().lower() + + +async def main(question: str) -> None: + base = ( + f"{question}\n\nReply as JSON: " + '{"reasoning": "", "answer": ""}' + ) + async with Conductor(".", "ma-debate-demo", launcher="resident", isolation="supervised") as c: + debaters = [ + await c.hire(f"debater{i}", runtime="claude", model="claude-haiku-4-5") + for i in range(N_DEBATERS) + ] + + # Round 0: independent answers, in parallel. + positions = list( + await asyncio.gather(*(d.ask(base, parse=parse_position) for d in debaters)) + ) + + # Debate rounds: each agent sees every OTHER agent's latest position + # and updates its own. Stop early on unanimity. + for round_no in range(1, ROUNDS + 1): + if len({canon(p["answer"]) for p in positions}) == 1: + print(f"converged before round {round_no}") + break + prompts = [] + for i, debater in enumerate(debaters): + others = "\n\n".join( + f"[{debaters[j].id}] answer: {p['answer']}\nreasoning: {p['reasoning']}" + for j, p in enumerate(positions) + if j != i + ) + prompts.append( + f"Other agents answered the same question:\n\n{others}\n\n" + "Consider their reasoning as additional evidence. Keep or " + f"update your answer.\n\n{base}" + ) + positions = list( + await asyncio.gather( + *(d.ask(p, parse=parse_position) for d, p in zip(debaters, prompts)) + ) + ) + answers = {canon(p["answer"]) for p in positions} + print(f"round {round_no}: {len(answers)} distinct answer(s)") + + # Majority over the final positions. + votes = Counter(canon(p["answer"]) for p in positions) + winner, n = votes.most_common(1)[0] + await c.note(f"multiagent debate: '{winner}' won {n}/{N_DEBATERS} votes") + print(f"\nfinal answer: {winner} ({n}/{N_DEBATERS} votes)") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_QUESTION)) diff --git a/examples/papers/negotiation.py b/examples/papers/negotiation.py new file mode 100644 index 0000000..75f6b8d --- /dev/null +++ b/examples/papers/negotiation.py @@ -0,0 +1,107 @@ +"""Improving Language Model Negotiation with Self-Play and In-Context +Learning from AI Feedback (Fu et al. 2023, arXiv:2305.10142) — bargaining +games where the coach's notes are the learning signal. + +Buyer and seller bargain over an item; after each game a critic reviews +the coached side's play and writes concrete strategy feedback; the next +game starts with all previous games and feedback in context. No weights +move — improvement (a better deal price, game over game) comes purely +from in-context learning on AI feedback. Moves are validated JSON, the +price trajectory is host-side state, and every game is journaled. + + python examples/papers/negotiation.py [""] +""" + +import asyncio +import sys +from typing import Any, Mapping + +from h5i.orchestra import Conductor + +DEMO_ITEM = "a used mountain bike in good condition, listed at $200" +N_GAMES = 3 +MAX_TURNS = 6 # moves per game (buyer and seller alternating) + + +def parse_move(value: Any) -> dict[str, Any]: + if not isinstance(value, Mapping) or "message" not in value: + raise ValueError( + 'reply must be {"message": "...", "offer": , ' + '"accept": true|false}' + ) + offer = value.get("offer") + return { + "message": str(value["message"]).strip(), + "offer": float(offer) if offer is not None else None, + "accept": bool(value.get("accept", False)), + } + + +def parse_text(value: Any) -> str: + return value if isinstance(value, str) else str(value) + + +async def main(item: str) -> None: + async with Conductor(".", "negotiation-demo", launcher="resident", isolation="supervised") as c: + buyer = await c.hire("buyer", runtime="claude", model="claude-haiku-4-5") + seller = await c.hire("seller", runtime="codex", model="gpt-5.4-mini", effort="medium") + critic = await c.hire("critic", runtime="claude", model="claude-haiku-4-5") + + lessons = "" # the buyer's accumulated AI feedback + prices: list[float | None] = [] + for game in range(1, N_GAMES + 1): + transcript = "" + deal: float | None = None + last_offer: float | None = None + for turn in range(MAX_TURNS): + mover, role_brief = ( + (buyer, "You are the BUYER: your goal is the LOWEST price.") + if turn % 2 == 0 + else (seller, "You are the SELLER: your goal is the HIGHEST price.") + ) + coaching = ( + f"\nYour coach's notes from earlier games:\n{lessons}\n" + if lessons and mover is buyer + else "" + ) + move = await mover.ask( + f"Negotiation over: {item}\n{role_brief}{coaching}\n" + f"Dialogue so far:\n{transcript or '(you open)'}\n\n" + "Make your next move: a short message, your current " + "offer (number, or null if none yet), and accept=true " + "ONLY if you take the opponent's last offer. Reply as " + 'JSON: {"message": "...", "offer": , ' + '"accept": true|false}', + parse=parse_move, + ) + who = "buyer" if mover is buyer else "seller" + transcript += f"{who}: {move['message']} (offer: {move['offer']})\n" + if move["accept"] and last_offer is not None: + deal = last_offer + break + if move["offer"] is not None: + last_offer = move["offer"] + prices.append(deal) + print(f"game {game}: " + (f"deal at ${deal:.0f}" if deal else "no deal")) + + if game == N_GAMES: + break + + # AI feedback: the critic coaches the buyer for the next game. + feedback = await critic.ask( + f"You coach the BUYER in negotiations over: {item}\n\n" + f"Game {game} transcript:\n{transcript}\n" + + (f"Deal price: ${deal:.0f}\n" if deal else "No deal was reached.\n") + + "Give 2-3 concrete strategy improvements for the buyer's " + "next game. Reply as a single JSON string.", + parse=parse_text, + ) + lessons += f"After game {game}: {feedback}\n" + + trajectory = ", ".join(f"${p:.0f}" if p else "no deal" for p in prices) + await c.note(f"negotiation self-play: deal trajectory {trajectory}") + print(f"\ndeal price trajectory (buyer coached): {trajectory}") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_ITEM)) diff --git a/examples/papers/parsel.py b/examples/papers/parsel.py new file mode 100644 index 0000000..2a04a62 --- /dev/null +++ b/examples/papers/parsel.py @@ -0,0 +1,107 @@ +"""Parsel: Algorithmic Reasoning with Language Models by Composing +Decompositions (Zelikman et al. 2023, arXiv:2212.10561) — a function graph, +implemented part by part, then composed. + +Parsel separates *what* from *how*: first decompose the problem into a +graph of function specifications (name, signature, one-line contract, +dependencies), then implement each function independently, then compose +and test the whole. The h5i mapping is one validated-JSON ``ask`` for the +decomposition, a ``map_reduce`` fan-out — one work assignment per spec, +the reducer composing the module per the dependency graph — and neutral +verification of the composition. The compositional counterpart of +``mapcoder.py``'s plan switching. + + python examples/papers/parsel.py [""] # default: implement quicksort with pytest +""" + +import asyncio +import json +import sys +from typing import Any, Mapping + +from h5i.orchestra import Conductor, patterns + +DEMO_TASK = "implement quicksort with pytest" +VERIFY = ["pytest", "-q"] +MAX_FUNCTIONS = 4 + + +def parse_functions(value: Any) -> list[dict[str, Any]]: + items = value.get("functions") if isinstance(value, Mapping) else None + if not isinstance(items, list) or not items: + raise ValueError( + 'reply must be {"functions": [{"name": "...", "signature": "...", ' + '"contract": "...", "uses": [""]}]}' + ) + functions = [] + for f in items[:MAX_FUNCTIONS]: + if not isinstance(f, Mapping) or "name" not in f: + raise ValueError("every function needs at least a name") + functions.append( + { + "name": str(f["name"]).strip(), + "signature": str(f.get("signature", "")).strip(), + "contract": str(f.get("contract", "")).strip(), + "uses": [str(u) for u in (f.get("uses") or [])], + } + ) + return functions + + +async def main(task: str) -> None: + async with Conductor(".", "parsel-demo", launcher="resident", isolation="supervised") as c: + decomposer = await c.hire("decomposer", runtime="claude", model="claude-haiku-4-5") + coders = [ + await c.hire("coder0", runtime="claude", model="claude-haiku-4-5"), + await c.hire("coder1", runtime="codex", model="gpt-5.4-mini", effort="medium"), + ] + composer = await c.hire("composer", runtime="claude", model="claude-haiku-4-5") + + # 1. Decompose into a function graph — the "what". + functions = await decomposer.ask( + f"Task: {task}\n\nDecompose it into at most {MAX_FUNCTIONS} " + "functions. For each give: name, signature, a one-line contract, " + "and which of the other functions it uses. Reply as JSON: " + '{"functions": [{"name": "...", "signature": "...", ' + '"contract": "...", "uses": ["..."]}]}', + parse=parse_functions, + ) + graph = json.dumps(functions, indent=2) + print("function graph:") + for f in functions: + uses = f" ← uses {', '.join(f['uses'])}" if f["uses"] else "" + print(f" {f['name']}{f['signature']}{uses}") + + # 2-3. Implement each spec independently, then compose — the "how". + # map_reduce: cross-agent parallel, and the reducer gets every part + # as sealed-phase materials. + outcome = await patterns.map_reduce( + c, + [ + ( + coders[i % len(coders)], + f"Implement EXACTLY this function, with unit tests for " + f"it (assume the other functions in the graph exist as " + f"specified):\n{json.dumps(f, indent=2)}\n\n" + f"Full graph for context:\n{graph}\n\nPart of: {task}", + ) + for i, f in enumerate(functions) + ], + reduce=( + composer, + f"Compose the granted per-function implementations into one " + f"coherent module for: {task}\nWire them per this dependency " + f"graph, resolve duplicate helpers, keep all tests:\n{graph}", + ), + ) + merged = outcome.merged + assert merged is not None + + # 4. Test the composition, not the parts. + await c.verify(merged, VERIFY) + verdict = await c.judge() + print("verdict:", verdict.selected_submission or "none", "—", *verdict.reasons) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/persuasive_debate.py b/examples/papers/persuasive_debate.py new file mode 100644 index 0000000..de03528 --- /dev/null +++ b/examples/papers/persuasive_debate.py @@ -0,0 +1,120 @@ +"""Debating with More Persuasive LLMs Leads to More Truthful Answers (Khan +et al. 2024, arXiv:2402.06782) — assigned-side debate as a scalable +oversight protocol. + +The oversight question: can a judge reach the right answer by watching +informed debaters argue, without doing the work itself? Two debaters are +*assigned* opposing candidate answers — neither chose its side — and argue +over rounds; the judge decides from the transcript alone. The score also +records the judge's pre-debate snap answer, so one run shows the paper's +core comparison: naive judgment vs. judgment after adversarial argument. + + python examples/papers/persuasive_debate.py [""] +""" + +import asyncio +import sys +from typing import Any, Mapping + +from h5i.orchestra import Conductor + +DEMO_QUESTION = ( + "Is it ever acceptable for a code reviewer to approve a change they " + "do not fully understand, in a professional engineering team?" +) +ROUNDS = 2 + + +def parse_sides(value: Any) -> dict[str, str]: + if ( + not isinstance(value, Mapping) + or "answer_a" not in value + or "answer_b" not in value + ): + raise ValueError('reply must be {"answer_a": "...", "answer_b": "..."}') + return {"A": str(value["answer_a"]).strip(), "B": str(value["answer_b"]).strip()} + + +def parse_choice(value: Any) -> dict[str, Any]: + if not isinstance(value, Mapping) or "choice" not in value: + raise ValueError( + 'reply must be {"choice": "A"|"B", "confidence": <0.0-1.0>, "reason": "..."}' + ) + choice = str(value["choice"]).strip().upper() + if choice not in {"A", "B"}: + raise ValueError('choice must be "A" or "B"') + return { + "choice": choice, + "confidence": min(1.0, max(0.0, float(value.get("confidence", 0.5)))), + "reason": str(value.get("reason", "")).strip(), + } + + +def parse_text(value: Any) -> str: + return value if isinstance(value, str) else str(value) + + +async def main(question: str) -> None: + async with Conductor(".", "persuasion-demo", launcher="resident", isolation="supervised") as c: + setter = await c.hire("setter", runtime="claude", model="claude-haiku-4-5") + debater_a = await c.hire("debater-a", runtime="claude", model="claude-haiku-4-5") + debater_b = await c.hire("debater-b", runtime="codex", model="gpt-5.4-mini", effort="medium") + judge = await c.hire("judge", runtime="claude", model="claude-haiku-4-5") + + # Two plausible opposing candidate answers; sides are ASSIGNED. + sides = await setter.ask( + f"Question: {question}\n\nWrite the two most defensible OPPOSING " + 'answers. Reply as JSON: {"answer_a": "...", "answer_b": "..."}', + parse=parse_sides, + ) + print(f"A: {sides['A']}\nB: {sides['B']}\n") + + # Baseline: the judge's snap answer before hearing any argument. + naive = await judge.ask( + f"Question: {question}\n\nCandidate answers:\nA: {sides['A']}\n" + f"B: {sides['B']}\n\nPick one, honestly, without further help. " + 'Reply as JSON: {"choice": "A"|"B", "confidence": <0.0-1.0>, ' + '"reason": "..."}', + parse=parse_choice, + ) + print(f"naive judge: {naive['choice']} (confidence {naive['confidence']:.2f})\n") + + # Assigned-side debate: each debater must argue ITS side. + transcript = "" + for round_no in range(1, ROUNDS + 1): + for debater, side in ((debater_a, "A"), (debater_b, "B")): + argument = await debater.ask( + f"Question: {question}\nYou are ASSIGNED to defend answer " + f"{side}: {sides[side]}\nYou did not choose this side; " + "argue it as persuasively and honestly as you can.\n\n" + f"Debate so far:\n{transcript or '(you open)'}\n\n" + f"Round {round_no}/{ROUNDS}: make your strongest case, " + "rebutting your opponent where possible. Reply as a " + "single JSON string.", + parse=parse_text, + ) + transcript += f"[{side}] {argument}\n" + + # The judge decides from the transcript alone. + informed = await judge.ask( + f"Question: {question}\n\nCandidate answers:\nA: {sides['A']}\n" + f"B: {sides['B']}\n\nDebate transcript:\n{transcript}\n" + "Judge ONLY from the arguments above: which answer prevailed? " + 'Reply as JSON: {"choice": "A"|"B", "confidence": <0.0-1.0>, ' + '"reason": "..."}', + parse=parse_choice, + ) + await c.note( + f"persuasive debate: naive={naive['choice']}@{naive['confidence']:.2f} " + f"→ informed={informed['choice']}@{informed['confidence']:.2f}" + ) + print( + f"informed judge: {informed['choice']} " + f"(confidence {informed['confidence']:.2f}) — {informed['reason']}" + ) + winner = sides[informed["choice"]] + print(f"\nprevailing answer: {winner}") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_QUESTION)) diff --git a/examples/papers/prd_peer_rank.py b/examples/papers/prd_peer_rank.py new file mode 100644 index 0000000..d7b5360 --- /dev/null +++ b/examples/papers/prd_peer_rank.py @@ -0,0 +1,127 @@ +"""PRD: Peer Rank and Discussion Improve Large Language Model based +Evaluations (Li et al. 2023, arXiv:2307.02762) — the contestants are also +the jury, weighted by how much the jury trusts them. + +Single-judge evaluation suffers self-enhancement and position bias. PRD's +peer rank: every contestant judges every answer pair (both presentation +orders), and each reviewer's vote is weighted by how much it agrees with +the aggregate — a reviewer the panel disagrees with loses influence. Peer +discussion then lets two reviewers argue the closest call to a mutual +verdict. All of it is ``ask`` turns over a model-diverse pool plus a +little matrix arithmetic in host code. + + python examples/papers/prd_peer_rank.py [""] +""" + +import asyncio +import sys +from itertools import combinations +from typing import Any, Mapping + +from h5i.orchestra import Agent, Conductor + +DEMO_TASK = ( + "Explain what eventual consistency means in distributed systems, with " + "one concrete example of where it is acceptable and one where it is not." +) + + +def parse_text(value: Any) -> str: + return value if isinstance(value, str) else str(value) + + +def parse_winner(value: Any) -> str: + label = value.get("winner") if isinstance(value, Mapping) else value + if not isinstance(label, str) or label.strip().upper() not in {"A", "B"}: + raise ValueError('reply must be {"winner": "A"} or {"winner": "B"}') + return label.strip().upper() + + +async def main(task: str) -> None: + async with Conductor(".", "prd-demo", launcher="resident", isolation="supervised") as c: + contestants = [ + await c.hire("peer0", runtime="claude", model="claude-haiku-4-5"), + await c.hire("peer1", runtime="codex", model="gpt-5.4-mini", effort="medium"), + await c.hire("peer2", runtime="claude", model="claude-haiku-4-5"), + ] + + # Every peer answers the instruction. + answers = list( + await asyncio.gather( + *( + p.ask(f"{task}\n\nReply as a single JSON string.", parse=parse_text) + for p in contestants + ) + ) + ) + pairs = list(combinations(range(len(contestants)), 2)) + + # Peer rank: every peer judges every pair, both orders (position + # bias washes out); sequential within a reviewer, parallel across. + async def review_all(reviewer: Agent) -> dict[tuple[int, int], int]: + prefs: dict[tuple[int, int], int] = {} + for i, j in pairs: + votes = [] + for first, second in ((i, j), (j, i)): + label = await reviewer.ask( + f"Instruction: {task}\n\nAnswer A:\n{answers[first]}\n\n" + f"Answer B:\n{answers[second]}\n\nWhich answer is " + 'better? Reply as JSON: {"winner": "A"} or {"winner": "B"}', + parse=parse_winner, + ) + votes.append(first if label == "A" else second) + prefs[(i, j)] = votes[0] if votes[0] == votes[1] else -1 # -1 = tie + return prefs + + all_prefs = await asyncio.gather(*(review_all(r) for r in contestants)) + + def scores(weights: list[float]) -> list[float]: + out = [0.0] * len(contestants) + for reviewer_idx, prefs in enumerate(all_prefs): + for (i, j), winner in prefs.items(): + if winner == -1: + out[i] += weights[reviewer_idx] / 2 + out[j] += weights[reviewer_idx] / 2 + else: + out[winner] += weights[reviewer_idx] + return out + + # Round 1: uniform weights. Round 2: a reviewer's weight is its + # agreement with the aggregate preference — the peer-trust update. + base = scores([1.0] * len(contestants)) + consensus = {(i, j): (i if base[i] >= base[j] else j) for i, j in pairs} + weights = [] + for prefs in all_prefs: + agreed = sum(1 for pair, winner in prefs.items() if winner == consensus[pair]) + weights.append(agreed / len(pairs)) + weighted = scores(weights) + + for idx, p in enumerate(contestants): + print( + f"{p.id}: raw {base[idx]:.1f}, weighted {weighted[idx]:.2f} " + f"(reviewer weight {weights[idx]:.2f})" + ) + + # Peer discussion on the closest call between the top two. + top = sorted(range(len(contestants)), key=lambda k: -weighted[k])[:2] + r1, r2 = contestants[top[1]], contestants[top[0]] + opinion = await r1.ask( + f"Instruction: {task}\n\nAnswer A:\n{answers[top[0]]}\n\n" + f"Answer B:\n{answers[top[1]]}\n\nGive your preference and the " + "reasons. Reply as a single JSON string.", + parse=parse_text, + ) + verdict = await r2.ask( + f"Instruction: {task}\n\nAnswer A:\n{answers[top[0]]}\n\n" + f"Answer B:\n{answers[top[1]]}\n\nA fellow reviewer argued:\n" + f"{opinion}\n\nDiscuss and settle it: which answer wins? Reply " + 'as JSON: {"winner": "A"} or {"winner": "B"}', + parse=parse_winner, + ) + final = top[0] if verdict == "A" else top[1] + await c.note(f"prd: {contestants[final].id} won after peer rank + discussion") + print(f"\nfinal winner after discussion: {contestants[final].id}") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/reconcile.py b/examples/papers/reconcile.py new file mode 100644 index 0000000..d943dcb --- /dev/null +++ b/examples/papers/reconcile.py @@ -0,0 +1,110 @@ +"""ReConcile: Round-Table Conference Improves Reasoning via Consensus among +Diverse LLMs (Chen et al. 2024, arXiv:2309.13007) — confidence-weighted +consensus across heterogeneous models. + +Three properties distinguish ReConcile from plain debate: the table is +*model-diverse* (here: a Claude seat, a Codex seat, and a third seat — swap +in a third vendor's runtime if you have one installed), every position +carries a *confidence estimate*, and each discussion round shares everyone's +answer + confidence + explanation so agents can be convinced by a better +explanation rather than by repetition. The final answer is a +confidence-weighted vote, not a head count. + + python examples/papers/reconcile.py [""] +""" + +import asyncio +import sys +from typing import Any, Mapping + +from h5i.orchestra import Conductor + +DEMO_QUESTION = ( + "You have two ropes; each takes exactly 60 minutes to burn but burns " + "unevenly. How do you measure exactly 45 minutes?" +) +ROUNDS = 2 + +POSITION_SHAPE = ( + 'Reply as JSON: {"answer": "", "confidence": <0.0-1.0>, ' + '"explanation": ""}' +) + + +def parse_position(value: Any) -> dict[str, Any]: + if not isinstance(value, Mapping) or "answer" not in value: + raise ValueError(POSITION_SHAPE) + try: + confidence = float(value.get("confidence", 0.5)) + except (TypeError, ValueError) as e: + raise ValueError(f"confidence must be a number 0.0-1.0 ({e})") + return { + "answer": str(value["answer"]).strip(), + "confidence": min(1.0, max(0.0, confidence)), + "explanation": str(value.get("explanation", "")).strip(), + } + + +def canon(answer: str) -> str: + return answer.strip().lower() + + +def table_view(names: list[str], positions: list[dict[str, Any]]) -> str: + return "\n\n".join( + f"[{name}] answer: {p['answer']} (confidence {p['confidence']:.2f})\n" + f"explanation: {p['explanation']}" + for name, p in zip(names, positions) + ) + + +async def main(question: str) -> None: + base = f"Question: {question}\n\n{POSITION_SHAPE}" + async with Conductor(".", "reconcile-demo", launcher="resident", isolation="supervised") as c: + table = [ + await c.hire("haiku-seat", runtime="claude", model="claude-haiku-4-5"), + await c.hire("mini-seat", runtime="codex", model="gpt-5.4-mini", effort="medium"), + await c.hire("third-seat", runtime="claude", model="claude-haiku-4-5"), + ] + names = [seat.id for seat in table] + + # Initial phase: independent positions with confidence. + positions = list( + await asyncio.gather(*(seat.ask(base, parse=parse_position) for seat in table)) + ) + + # Discussion rounds: everyone sees the whole table (answers, + # confidences, explanations) and may be convinced. + for round_no in range(1, ROUNDS + 1): + if len({canon(p["answer"]) for p in positions}) == 1: + print(f"consensus before round {round_no}") + break + grouped = table_view(names, positions) + positions = list( + await asyncio.gather( + *( + seat.ask( + f"Round-table, round {round_no}. Every " + f"participant's current position:\n\n{grouped}\n\n" + "Weigh the explanations — change your answer only " + f"if another is more convincing.\n\n{base}", + parse=parse_position, + ) + for seat in table + ) + ) + ) + print(f"round {round_no}: {len({canon(p['answer']) for p in positions})} distinct answer(s)") + + # Confidence-weighted vote. + weight: dict[str, float] = {} + for p in positions: + weight[canon(p["answer"])] = weight.get(canon(p["answer"]), 0.0) + p["confidence"] + winner = max(weight, key=lambda k: weight[k]) + for name, p in zip(names, positions): + print(f"[{name}] {p['answer']} (confidence {p['confidence']:.2f})") + await c.note(f"reconcile: '{winner}' won with weight {weight[winner]:.2f}") + print(f"\nweighted consensus: {winner} (weight {weight[winner]:.2f})") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_QUESTION)) diff --git a/examples/papers/reflexion.py b/examples/papers/reflexion.py new file mode 100644 index 0000000..f5cfae5 --- /dev/null +++ b/examples/papers/reflexion.py @@ -0,0 +1,89 @@ +"""Reflexion: Language Agents with Verbal Reinforcement Learning (Shinn et +al. 2023, arXiv:2303.11366) — actor / evaluator / self-reflection. + +The actor attempts the task; an evaluator returns a sparse pass/fail; on +failure the actor verbalizes *why* it failed, and that reflection lands in +an episodic memory that rides along with every later trial. No weights move +— the "reinforcement" is text. The h5i mapping: the evaluator is +``conductor.verify`` (a real command in a fresh worktree, not an LLM +opinion), the reflection is an ``ask`` data turn, and the episodic memory is +delivered as a constructed ``Review`` the actor revises against — so every +trial, reflection, and retry is journaled and resumable. + + python examples/papers/reflexion.py [""] # default: implement quicksort with pytest +""" + +import asyncio +import sys + +from h5i.orchestra import Conductor, Review, Verification + +DEMO_TASK = "implement quicksort with pytest" +VERIFY = ["pytest", "-q"] +MAX_TRIALS = 3 + + +def describe(v: Verification) -> str: + line = ( + f"`{' '.join(v.command)}`: " + + ("applied cleanly" if v.applies_cleanly else "failed to apply") + + ", " + + ("tests passed" if v.tests_passed else "tests failed") + ) + if v.failure: + line += f"\nfailure: {v.failure}" + return line + + +async def main(task: str) -> None: + async with Conductor(".", "reflexion-demo", launcher="resident", isolation="supervised") as c: + actor = await c.hire("actor", runtime="claude", model="claude-haiku-4-5") + + artifact = await actor.work(task, expect_independent=True) + await c.freeze() + + memory: list[str] = [] # the episodic reflection buffer + for trial in range(1, MAX_TRIALS + 1): + verification = await c.verify(artifact, VERIFY) + if verification.applies_cleanly and verification.tests_passed: + print(f"trial {trial}: evaluator is green") + break + evidence = describe(verification) + print(f"trial {trial}: evaluator rejected ({evidence.splitlines()[0]})") + if trial == MAX_TRIALS: + break + + # Self-reflection: turn the sparse fail signal into a verbal lesson. + reflection = await actor.ask( + "Your submission failed neutral verification.\n" + f"{evidence}\n\n" + "Reflect: in 2-3 sentences, state what likely went wrong and " + "what you will do differently next trial. Reply as a single " + "JSON string.", + parse=lambda v: v if isinstance(v, str) else str(v), + ) + memory.append(reflection) + + # Next trial: the whole episodic memory rides in as the review. + lessons = "\n".join(f"- {r}" for r in memory) + artifact = await actor.revise( + artifact, + Review( + reviewer="reflexion-memory", + target=actor.id, + round=artifact.round, + body=( + "Verdict: REVISE\n\nYour reflections from earlier " + f"trials:\n{lessons}\n\nApply these lessons and fix " + f"the failure:\n{evidence}" + ), + referenced_artifacts=(artifact.id,), + ), + ) + + verdict = await c.judge() + print("verdict:", verdict.selected_submission or "none", "—", *verdict.reasons) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/self_consistency.py b/examples/papers/self_consistency.py new file mode 100644 index 0000000..f924fca --- /dev/null +++ b/examples/papers/self_consistency.py @@ -0,0 +1,67 @@ +"""Self-Consistency Improves Chain of Thought Reasoning in Language Models +(Wang et al. 2023, arXiv:2203.11171) — sample diverse reasoning paths, +marginalize by majority vote. + +One greedy chain of thought is brittle; many independently sampled chains +converge on the right answer more often than any single one. Diversity here +comes from N independent seats — each its own session, asked the same +question in parallel with no cross-influence — and the vote is ordinary +Python over the journaled replies. Pure ``ask`` data turns: no artifacts, +no freeze. Swap ``canon`` for a similarity kernel to vote over open-ended +answers. + + python examples/papers/self_consistency.py [""] +""" + +import asyncio +import sys +from collections import Counter +from typing import Any, Mapping + +from h5i.orchestra import Conductor + +DEMO_QUESTION = ( + "A bat and a ball cost $1.10 in total. The bat costs $1.00 more than " + "the ball. How much does the ball cost, in cents?" +) +N_SAMPLES = 5 + + +def parse_answer(value: Any) -> str: + if not isinstance(value, Mapping) or "answer" not in value: + raise ValueError('reply must be {"reasoning": "...", "answer": "..."}') + return str(value["answer"]).strip() + + +def canon(answer: str) -> str: + return answer.strip().lower() + + +async def main(question: str) -> None: + prompt = ( + f"{question}\n\nReason step by step, then reply as JSON: " + '{"reasoning": "", ' + '"answer": ""}' + ) + async with Conductor(".", "self-consistency-demo", launcher="resident", isolation="supervised") as c: + seats = [ + await c.hire(f"sampler{i}", runtime="claude", model="claude-haiku-4-5") + for i in range(N_SAMPLES) + ] + + # N independent reasoning paths, in parallel (one ask per seat). + answers = await asyncio.gather( + *(seat.ask(prompt, parse=parse_answer) for seat in seats) + ) + + # Marginalize out the reasoning: majority over final answers. + votes = Counter(canon(a) for a in answers) + for answer, n in votes.most_common(): + print(f"{n}/{len(answers)} {answer}") + winner, n = votes.most_common(1)[0] + await c.note(f"self-consistency: '{winner}' won {n}/{len(answers)} votes") + print(f"\nconsensus answer: {winner}") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_QUESTION)) diff --git a/examples/papers/self_debugging.py b/examples/papers/self_debugging.py new file mode 100644 index 0000000..3c1e938 --- /dev/null +++ b/examples/papers/self_debugging.py @@ -0,0 +1,87 @@ +"""Teaching Large Language Models to Self-Debug (Chen et al. 2023, +arXiv:2304.05128) — rubber-duck debugging: explain your own code before +fixing it. + +Self-Debug's key finding: even without any error message, making the model +*explain its code line by line* surfaces the bug — and with real execution +feedback it works better still. Each cycle here: neutral execution +(``conductor.verify``), then an explanation ``ask`` (the rubber-duck step +— what does each part actually do, where could the observed failure come +from?), then a revise turn against the explanation + evidence. Compare +``reflexion.py`` (lessons about the *approach*) and ``critic.py`` (tool +outputs alone): here the feedback is the model's own code walkthrough. + + python examples/papers/self_debugging.py [""] # default: implement quicksort with pytest +""" + +import asyncio +import sys + +from h5i.orchestra import Conductor, Review, Verification + +DEMO_TASK = "implement quicksort with pytest" +VERIFY = ["pytest", "-q"] +MAX_CYCLES = 3 + + +def describe(v: Verification) -> str: + line = ( + f"`{' '.join(v.command)}`: " + + ("applied cleanly" if v.applies_cleanly else "failed to apply") + + ", " + + ("tests passed" if v.tests_passed else "tests FAILED") + ) + if v.failure: + line += f"\nfailure: {v.failure}" + return line + + +async def main(task: str) -> None: + async with Conductor(".", "selfdebug-demo", launcher="resident", isolation="supervised") as c: + coder = await c.hire("coder", runtime="claude", model="claude-haiku-4-5") + + artifact = await coder.work(task, expect_independent=True) + await c.freeze() + + for cycle in range(1, MAX_CYCLES + 1): + verification = await c.verify(artifact, VERIFY) + if verification.applies_cleanly and verification.tests_passed: + print(f"cycle {cycle}: green") + break + evidence = describe(verification) + print(f"cycle {cycle}: failed — rubber-duck round") + if cycle == MAX_CYCLES: + break + + # The rubber-duck step: explain the code, then locate the bug. + explanation = await coder.ask( + "Your submission failed:\n" + f"{evidence}\n\n" + "Rubber-duck it: walk through YOUR OWN code section by " + "section — what each part actually does (not what it is " + "meant to do) — then state where the observed failure most " + "likely comes from. Reply as a single JSON string.", + parse=lambda v: v if isinstance(v, str) else str(v), + ) + + artifact = await coder.revise( + artifact, + Review( + reviewer="rubber-duck", + target=coder.id, + round=artifact.round, + body=( + "Verdict: REVISE\n\nYour own walkthrough of the " + f"code:\n{explanation}\n\nExecution evidence:\n" + f"{evidence}\n\nFix the located bug." + ), + referenced_artifacts=(artifact.id,), + ), + ) + + verdict = await c.judge() + print("verdict:", verdict.selected_submission or "none", "—", *verdict.reasons) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/self_refine.py b/examples/papers/self_refine.py new file mode 100644 index 0000000..da47207 --- /dev/null +++ b/examples/papers/self_refine.py @@ -0,0 +1,53 @@ +"""Self-Refine: Iterative Refinement with Self-Feedback (Madaan et al. 2023, +arXiv:2303.17651) — the generate → FEEDBACK → REFINE loop. + +One model plays two roles with two prompts: it generates a candidate, +critiques its own output, then refines against that critique — no external +signal, no training. h5i forbids a seat from reviewing its own artifact +(reviews are provenance-tracked turns), so the critic role is a second seat +pinned to the *same* model: the paper's "self" is the model, not the +session. The loop stops when the critic approves (the ``APPROVE`` first-line +convention) or the round budget runs out. + + python examples/papers/self_refine.py [""] # default: implement quicksort with pytest +""" + +import asyncio +import sys + +from h5i.orchestra import Conductor + +DEMO_TASK = "implement quicksort with pytest" +MAX_ROUNDS = 3 + + +async def main(task: str) -> None: + async with Conductor(".", "self-refine-demo", launcher="resident", isolation="supervised") as c: + author = await c.hire("author", runtime="claude", model="claude-haiku-4-5") + critic = await c.hire("critic", runtime="claude", model="claude-haiku-4-5") + + # Generate: one independent attempt, then seal the round (review is + # a sealed-phase turn). + artifact = await author.work(task, expect_independent=True) + await c.freeze() + + # Feedback → refine, until the critic approves or the budget is spent. + refinements = 0 + for _ in range(MAX_ROUNDS): + review = await critic.review(artifact) + if review.approved: + break + refinements += 1 + artifact = await author.revise(artifact, review) + + print(f"refined {refinements} time(s); final candidate {artifact.id}") + + # Not part of Self-Refine — finalize the run so the journal ends with + # neutral evidence and a recorded verdict. + await c.verify(artifact, ["pytest", "-q"]) + verdict = await c.judge() + print("verdict:", verdict.selected_submission or "none", "—", *verdict.reasons) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/selfcheckgpt.py b/examples/papers/selfcheckgpt.py new file mode 100644 index 0000000..a3d2e85 --- /dev/null +++ b/examples/papers/selfcheckgpt.py @@ -0,0 +1,99 @@ +"""SelfCheckGPT: Zero-Resource Black-Box Hallucination Detection for +Generative Large Language Models (Manakul et al. 2023, arXiv:2303.08896) — +consistency across independent samples as a truth signal. + +The premise: when a model *knows* a fact, independent samples agree on it; +hallucinations scatter. So: take a primary answer, draw N independent +samples of the same question from separate seats, then have a checker +score each sentence of the primary answer against each sample — +low-support sentences get flagged. No external database, no logits; just +sampling and comparison over journaled ``ask`` turns. + + python examples/papers/selfcheckgpt.py [""] +""" + +import asyncio +import re +import sys +from typing import Any + +from h5i.orchestra import Conductor + +DEMO_QUESTION = "Give a short factual biography of Alan Turing (4-6 sentences)." +N_SAMPLES = 3 +SUPPORT_THRESHOLD = 0.5 # below this mean support, a sentence is flagged + + +def parse_text(value: Any) -> str: + return value if isinstance(value, str) else str(value) + + +def parse_scores(expected: int): + def parse(value: Any) -> list[float]: + scores = value.get("scores") if isinstance(value, dict) else value + if not isinstance(scores, list) or len(scores) != expected: + raise ValueError( + f'reply must be {{"scores": [<{expected} numbers, 0=contradicted, ' + "0.5=not mentioned, 1=supported>]}}" + ) + return [max(0.0, min(1.0, float(s))) for s in scores] + + return parse + + +def sentences_of(text: str) -> list[str]: + return [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()] + + +async def main(question: str) -> None: + async with Conductor(".", "selfcheck-demo", launcher="resident", isolation="supervised") as c: + primary = await c.hire("primary", runtime="claude", model="claude-haiku-4-5") + samplers = [ + await c.hire(f"sampler{i}", runtime="claude", model="claude-haiku-4-5") + for i in range(N_SAMPLES) + ] + checker = await c.hire("checker", runtime="claude", model="claude-haiku-4-5") + + # The answer under scrutiny + N independent samples, in parallel. + answer, *samples = await asyncio.gather( + primary.ask(f"{question}\n\nReply as a single JSON string.", parse=parse_text), + *( + s.ask(f"{question}\n\nReply as a single JSON string.", parse=parse_text) + for s in samplers + ), + ) + sentences = sentences_of(answer) + numbered = "\n".join(f"{i + 1}. {s}" for i, s in enumerate(sentences)) + + # Per-sentence support, checked against each sample separately. + support = [0.0] * len(sentences) + for sample_no, sample in enumerate(samples, 1): + scores = await checker.ask( + "Check each numbered sentence against the reference passage." + f"\n\nSentences:\n{numbered}\n\nReference passage:\n{sample}\n\n" + "For each sentence: 1 if the reference supports it, 0 if the " + "reference contradicts it, 0.5 if the reference does not " + 'mention it. Reply as JSON: {"scores": ' + f"[<{len(sentences)} numbers>]}}", + parse=parse_scores(len(sentences)), + ) + support = [acc + s for acc, s in zip(support, scores)] + print(f"checked against sample {sample_no}/{len(samples)}") + + # Flag the low-consistency sentences. + flagged = 0 + print("\nannotated answer:") + for sentence, total in zip(sentences, support): + mean = total / len(samples) + mark = " " if mean >= SUPPORT_THRESHOLD else "⚠" + if mark == "⚠": + flagged += 1 + print(f" {mark} [{mean:.2f}] {sentence}") + await c.note( + f"selfcheckgpt: {flagged}/{len(sentences)} sentences below " + f"{SUPPORT_THRESHOLD} support across {len(samples)} samples" + ) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_QUESTION)) diff --git a/examples/papers/skeleton_of_thought.py b/examples/papers/skeleton_of_thought.py new file mode 100644 index 0000000..9174cc6 --- /dev/null +++ b/examples/papers/skeleton_of_thought.py @@ -0,0 +1,91 @@ +"""Skeleton-of-Thought: Prompting LLMs for Efficient Parallel Generation +(Ning et al. 2024, arXiv:2307.15337) — skeleton first, points expanded in +parallel. + +SoT is a latency technique with a quality side-effect: first elicit a +short skeleton of the answer, then expand every point *simultaneously* and +assemble. Sequential decoding of a long answer becomes a handful of short +parallel completions. The h5i mapping is direct: one skeleton ``ask``, +then a cross-seat ``asyncio.gather`` — points are distributed over a seat +pool, parallel across seats and sequential within one (one resident +session per seat) — and host code stitches the answer back in order. + + python examples/papers/skeleton_of_thought.py [""] +""" + +import asyncio +import sys +from typing import Any + +from h5i.orchestra import Agent, Conductor + +DEMO_QUESTION = ( + "What are the main trade-offs when choosing between SQL and NoSQL " + "databases for a new product?" +) +MAX_POINTS = 5 + + +def parse_skeleton(value: Any) -> list[str]: + if not isinstance(value, list) or not value: + raise ValueError("reply must be a non-empty JSON array of short point strings") + return [str(p).strip() for p in value][:MAX_POINTS] + + +def parse_text(value: Any) -> str: + return value if isinstance(value, str) else str(value) + + +async def main(question: str) -> None: + async with Conductor(".", "sot-demo", launcher="resident", isolation="supervised") as c: + planner = await c.hire("planner", runtime="claude", model="claude-haiku-4-5") + writers = [ + await c.hire(f"writer{i}", runtime="claude", model="claude-haiku-4-5") + for i in range(3) + ] + + # 1. The skeleton: short points only, no elaboration yet. + skeleton = await planner.ask( + f"Question: {question}\n\nWrite ONLY the skeleton of the answer: " + f"3-{MAX_POINTS} points, each 3-6 words. Reply as a JSON array " + "of strings.", + parse=parse_skeleton, + ) + print("skeleton:", " | ".join(skeleton)) + + # 2. Point expansion — the parallel part. Distribute points over + # the writer pool: parallel across seats, sequential within a seat. + assignments: dict[str, tuple[Agent, list[int]]] = {} + for i in range(len(skeleton)): + writer = writers[i % len(writers)] + assignments.setdefault(writer.id, (writer, []))[1].append(i) + + async def expand(writer: Agent, indices: list[int]) -> list[tuple[int, str]]: + out = [] + for i in indices: + text = await writer.ask( + f"Question: {question}\n\nFull skeleton:\n" + + "\n".join(f"{n + 1}. {p}" for n, p in enumerate(skeleton)) + + f"\n\nWrite ONLY the expansion of point {i + 1} " + f"('{skeleton[i]}'): 2-3 sentences, no preamble. Reply " + "as a single JSON string.", + parse=parse_text, + ) + out.append((i, text)) + return out + + groups = await asyncio.gather( + *(expand(writer, indices) for writer, indices in assignments.values()) + ) + + # 3. Assemble in skeleton order. + expanded = dict(pair for group in groups for pair in group) + answer = "\n\n".join( + f"{i + 1}. {skeleton[i]} — {expanded[i]}" for i in range(len(skeleton)) + ) + await c.note(f"skeleton-of-thought: {len(skeleton)} points expanded in parallel") + print(f"\n{answer}") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_QUESTION)) diff --git a/examples/papers/storm.py b/examples/papers/storm.py new file mode 100644 index 0000000..7df38e3 --- /dev/null +++ b/examples/papers/storm.py @@ -0,0 +1,101 @@ +"""Assisting in Writing Wikipedia-like Articles From Scratch with Large +Language Models (Shao et al. 2024, arXiv:2402.14207) — STORM's pre-writing +stage: perspectives → simulated interviews → outline → article. + +STORM's insight is that article *quality is decided before writing starts*: +discover the distinct perspectives a topic deserves, then research each one +by simulating a conversation — a curious writer asking pointed questions, +an expert answering — and only then outline and draft. Here each stage is +an ``ask`` data turn: perspectives and the outline are validated JSON, the +interviews are alternating turns between two seats, and every conversation +is journaled, so a killed run resumes mid-interview. + + python examples/papers/storm.py [""] +""" + +import asyncio +import sys +from typing import Any + +from h5i.orchestra import Conductor + +DEMO_TOPIC = "how git stores history internally" +N_PERSPECTIVES = 2 +INTERVIEW_TURNS = 2 + + +def parse_text(value: Any) -> str: + return value if isinstance(value, str) else str(value) + + +def parse_strings(cap: int): + def parse(value: Any) -> list[str]: + if not isinstance(value, list) or not value: + raise ValueError("reply must be a non-empty JSON array of strings") + return [str(s).strip() for s in value][:cap] + + return parse + + +async def main(topic: str) -> None: + async with Conductor(".", "storm-demo", launcher="resident", isolation="supervised") as c: + writer = await c.hire("writer", runtime="claude", model="claude-haiku-4-5") + expert = await c.hire("expert", runtime="codex", model="gpt-5.4-mini", effort="medium") + editor = await c.hire("editor", runtime="claude", model="claude-haiku-4-5") + + # 1. Perspective discovery: what distinct angles does this deserve? + perspectives = await writer.ask( + f"Topic: {topic}\n\nName {N_PERSPECTIVES} DISTINCT perspectives " + "an article on this topic should cover (e.g. practitioner, " + "historian, skeptic — whatever fits this topic). Reply as a JSON " + "array of short strings.", + parse=parse_strings(N_PERSPECTIVES), + ) + print("perspectives:", "; ".join(perspectives)) + + # 2. Simulated interviews: per perspective, the writer asks, the + # expert answers — grounded question asking, the heart of STORM. + notes: list[str] = [] + for perspective in perspectives: + dialogue = "" + for _ in range(INTERVIEW_TURNS): + question = await writer.ask( + f"You research '{topic}' from this perspective: " + f"{perspective}.\nConversation so far:\n{dialogue or '(start)'}\n" + "Ask the expert your single most informative next " + "question — specific, not generic. Reply as a JSON string.", + parse=parse_text, + ) + answer = await expert.ask( + f"You are a domain expert on '{topic}'. Answer concisely " + f"and concretely:\n{question}\n\nReply as a JSON string.", + parse=parse_text, + ) + dialogue += f"Q: {question}\nA: {answer}\n" + notes.append(f"[perspective: {perspective}]\n{dialogue}") + print(f"interviewed for '{perspective}' ({INTERVIEW_TURNS} turns)") + + # 3. Outline from the collected conversations. + outline = await writer.ask( + f"Topic: {topic}\n\nInterview notes:\n\n" + "\n\n".join(notes) + "\n\n" + "Draft the article outline: section titles in reading order. " + "Reply as a JSON array of strings (max 5).", + parse=parse_strings(5), + ) + print("outline:", " / ".join(outline)) + + # 4. Write the full article from outline + notes. + article = await editor.ask( + f"Write a well-organized article on: {topic}\n\n" + "Outline:\n" + "\n".join(f"- {s}" for s in outline) + "\n\n" + "Source notes:\n\n" + "\n\n".join(notes) + "\n\n" + "Ground every section in the notes. Markdown, one paragraph per " + "section. Reply as a single JSON string.", + parse=parse_text, + ) + await c.note(f"storm: article on '{topic}' from {len(perspectives)} interviews") + print(f"\n{article}") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TOPIC)) diff --git a/examples/papers/tree_of_thoughts.py b/examples/papers/tree_of_thoughts.py new file mode 100644 index 0000000..13b732a --- /dev/null +++ b/examples/papers/tree_of_thoughts.py @@ -0,0 +1,91 @@ +"""Tree of Thoughts: Deliberate Problem Solving with Large Language Models +(Yao et al. 2023, arXiv:2305.10601) — breadth-first search over partial +plans, then build the best leaf. + +A thought is one coherent planning step; a state is the plan so far. Each +level: a proposer expands every frontier state into k candidate thoughts, +an evaluator scores all new states in one vote turn, and the best b survive +(beam search — the paper's BFS variant). Search runs on cheap ``ask`` data +turns; only the winning plan pays for a real work turn. Backtracking falls +out of the beam: a state whose children all score poorly simply stops being +expanded. + + python examples/papers/tree_of_thoughts.py [""] # default: implement quicksort with pytest +""" + +import asyncio +import sys +from typing import Any + +from h5i.orchestra import Conductor + +DEMO_TASK = "implement quicksort with pytest" +K_THOUGHTS = 3 # thoughts proposed per state +BEAM = 2 # states kept per level +DEPTH = 2 # levels of the tree + + +def parse_thoughts(value: Any) -> list[str]: + if not isinstance(value, list) or not value: + raise ValueError("reply must be a non-empty JSON array of strings") + return [str(t).strip() for t in value][:K_THOUGHTS] + + +def parse_scores(expected: int): + def parse(value: Any) -> list[float]: + scores = value.get("scores") if isinstance(value, dict) else value + if not isinstance(scores, list) or len(scores) != expected: + raise ValueError(f'reply must be {{"scores": [<{expected} numbers 0-10>]}}') + return [max(0.0, min(10.0, float(s))) for s in scores] + + return parse + + +async def main(task: str) -> None: + async with Conductor(".", "tot-demo", launcher="resident", isolation="supervised") as c: + proposer = await c.hire("proposer", runtime="claude", model="claude-haiku-4-5") + evaluator = await c.hire("evaluator", runtime="claude", model="claude-haiku-4-5") + builder = await c.hire("builder", runtime="codex", model="gpt-5.4-mini", effort="medium") + + # BFS with a beam: expand every frontier state, score, keep the top b. + frontier: list[str] = [""] + for depth in range(1, DEPTH + 1): + children: list[str] = [] + for state in frontier: # one proposer seat → sequential expansion + thoughts = await proposer.ask( + f"Task: {task}\n\nPlan so far:\n{state or '(empty)'}\n" + f"Propose {K_THOUGHTS} DISTINCT candidate next steps — " + "different strategies, not rephrasings. Reply as a JSON " + "array of strings.", + parse=parse_thoughts, + ) + children.extend(f"{state}{depth}. {t}\n" for t in thoughts) + + numbered = "\n".join( + f"--- candidate {i + 1} ---\n{s}" for i, s in enumerate(children) + ) + scores = await evaluator.ask( + f"Task: {task}\n\nCandidate partial plans:\n{numbered}\n" + "Score how promising each plan is as a path to a correct, " + "complete solution (0-10). Reply as JSON: " + f'{{"scores": [<{len(children)} numbers>]}}', + parse=parse_scores(len(children)), + ) + ranked = sorted(zip(scores, children), key=lambda p: -p[0]) + frontier = [state for _, state in ranked[:BEAM]] + print(f"depth {depth}: kept {len(frontier)}/{len(children)} states " + f"(best score {ranked[0][0]:.1f})") + + best = frontier[0] + print(f"\nchosen plan:\n{best}") + + # Only the winning leaf pays for a real work turn. + artifact = await builder.work(f"{task}\n\nFollow this plan:\n{best}") + await c.freeze() + await c.verify(artifact, ["pytest", "-q"]) + verdict = await c.judge() + print("verdict:", verdict.selected_submission or "none", "—", *verdict.reasons) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK)) diff --git a/examples/papers/universal_self_consistency.py b/examples/papers/universal_self_consistency.py new file mode 100644 index 0000000..13100e2 --- /dev/null +++ b/examples/papers/universal_self_consistency.py @@ -0,0 +1,84 @@ +"""Universal Self-Consistency for Large Language Model Generation (Chen et +al. 2023, arXiv:2311.17311) — self-consistency for free-form answers. + +Classic self-consistency needs answers that can be exact-matched for a +vote; USC removes that constraint: sample N responses, then ask one +selector to read them all and pick *the most consistent one* — the answer +that best agrees with the sample population. This is the free-form sibling +of ``self_consistency.py``: the same parallel independent seats, with the +Counter swapped for a selector ``ask`` whose choice is validated against +the sample indices. + + python examples/papers/universal_self_consistency.py [""] +""" + +import asyncio +import sys +from typing import Any, Mapping + +from h5i.orchestra import Conductor + +DEMO_QUESTION = ( + "What were the main causes of the fall of the Western Roman Empire? " + "Answer in one short paragraph." +) +N_SAMPLES = 4 + + +def parse_text(value: Any) -> str: + return value if isinstance(value, str) else str(value) + + +def parse_choice(n: int): + def parse(value: Any) -> dict[str, Any]: + if not isinstance(value, Mapping) or "choice" not in value: + raise ValueError('reply must be {"choice": <1-based index>, "reason": "..."}') + choice = int(value["choice"]) + if not 1 <= choice <= n: + raise ValueError(f"choice must be between 1 and {n}") + return {"choice": choice, "reason": str(value.get("reason", "")).strip()} + + return parse + + +async def main(question: str) -> None: + async with Conductor(".", "usc-demo", launcher="resident", isolation="supervised") as c: + samplers = [ + await c.hire(f"sampler{i}", runtime="claude", model="claude-haiku-4-5") + for i in range(N_SAMPLES) + ] + selector = await c.hire("selector", runtime="claude", model="claude-haiku-4-5") + + # N independent free-form responses, in parallel. + responses = list( + await asyncio.gather( + *( + s.ask(f"{question}\n\nReply as a single JSON string.", parse=parse_text) + for s in samplers + ) + ) + ) + + # The universal vote: pick the response most consistent with the + # whole sample population. + numbered = "\n\n".join( + f"--- response {i + 1} ---\n{r}" for i, r in enumerate(responses) + ) + picked = await selector.ask( + f"Question: {question}\n\nIndependent responses:\n\n{numbered}\n\n" + "Select the single response that is MOST CONSISTENT with the " + "majority of the responses — the one whose claims the others " + 'agree with most. Reply as JSON: {"choice": <1-based index>, ' + '"reason": ""}', + parse=parse_choice(len(responses)), + ) + await c.note( + f"universal self-consistency: response {picked['choice']}/{N_SAMPLES} " + f"— {picked['reason']}" + ) + print(f"selected response {picked['choice']} ({picked['reason']}):\n") + print(responses[picked["choice"] - 1]) + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_QUESTION)) diff --git a/examples/arena_score.py b/examples/tutorial/arena_score.py similarity index 95% rename from examples/arena_score.py rename to examples/tutorial/arena_score.py index a5bf70e..3a7b88f 100644 --- a/examples/arena_score.py +++ b/examples/tutorial/arena_score.py @@ -7,7 +7,7 @@ the smallest green diff. The compare rows are the same arena view `h5i team compare` renders. - python examples/arena_score.py [""] # default: implement quicksort with pytest + python examples/tutorial/arena_score.py [""] # default: implement quicksort with pytest """ import asyncio diff --git a/examples/custom_control_flow.py b/examples/tutorial/custom_control_flow.py similarity index 96% rename from examples/custom_control_flow.py rename to examples/tutorial/custom_control_flow.py index c2022c5..1658a72 100644 --- a/examples/custom_control_flow.py +++ b/examples/tutorial/custom_control_flow.py @@ -4,7 +4,7 @@ `if`, a journaled fan-out over a dynamic work list, a custom (LLM-assisted) verdict policy, and a mid-run score migration marker. - python examples/custom_control_flow.py [""] # default: implement quicksort with pytest + python examples/tutorial/custom_control_flow.py [""] # default: implement quicksort with pytest """ import asyncio diff --git a/examples/debate_then_build.py b/examples/tutorial/debate_then_build.py similarity index 96% rename from examples/debate_then_build.py rename to examples/tutorial/debate_then_build.py index 17c4b49..12eb832 100644 --- a/examples/debate_then_build.py +++ b/examples/tutorial/debate_then_build.py @@ -5,7 +5,7 @@ Debate is pure `ask` (no artifacts, no freeze), so the winning side can go straight into a work turn afterwards, in the same run and journal. - python examples/debate_then_build.py [""] # default: implement quicksort with pytest + python examples/tutorial/debate_then_build.py [""] # default: implement quicksort with pytest """ import asyncio diff --git a/examples/ensemble_score.py b/examples/tutorial/ensemble_score.py similarity index 96% rename from examples/ensemble_score.py rename to examples/tutorial/ensemble_score.py index 79cb51d..4f9e018 100644 --- a/examples/ensemble_score.py +++ b/examples/tutorial/ensemble_score.py @@ -6,7 +6,7 @@ attached (bring them up with `launcher="resident"` to let the score spawn tmux sessions itself). - python examples/ensemble_score.py [""] # default: implement quicksort with pytest + python examples/tutorial/ensemble_score.py [""] # default: implement quicksort with pytest """ import asyncio diff --git a/examples/judge_panel_score.py b/examples/tutorial/judge_panel_score.py similarity index 95% rename from examples/judge_panel_score.py rename to examples/tutorial/judge_panel_score.py index c574df5..95baa0d 100644 --- a/examples/judge_panel_score.py +++ b/examples/tutorial/judge_panel_score.py @@ -9,7 +9,7 @@ Judges are read-only seats that must be hired before the round seals — alongside the workers, exactly like the roster note in patterns.py says. - python examples/judge_panel_score.py [""] # default: implement quicksort with pytest + python examples/tutorial/judge_panel_score.py [""] # default: implement quicksort with pytest """ import asyncio diff --git a/examples/pipeline_score.py b/examples/tutorial/pipeline_score.py similarity index 95% rename from examples/pipeline_score.py rename to examples/tutorial/pipeline_score.py index fae70d5..9ff0584 100644 --- a/examples/pipeline_score.py +++ b/examples/tutorial/pipeline_score.py @@ -6,7 +6,7 @@ Stage 1 works pre-freeze; the round seals right after it, because materials ride the sealed-phase-only discuss channel. - python examples/pipeline_score.py [""] # default: implement quicksort with pytest + python examples/tutorial/pipeline_score.py [""] # default: implement quicksort with pytest """ import asyncio diff --git a/examples/quorum_ensemble.py b/examples/tutorial/quorum_ensemble.py similarity index 97% rename from examples/quorum_ensemble.py rename to examples/tutorial/quorum_ensemble.py index 926a4f9..ac2e225 100644 --- a/examples/quorum_ensemble.py +++ b/examples/tutorial/quorum_ensemble.py @@ -17,7 +17,7 @@ before the freeze (enrollment is open-round-only), and remember each turn is journaled: a killed run resumes without re-paying completed turns. - python examples/quorum_ensemble.py [""] # default: implement quicksort with pytest + python examples/tutorial/quorum_ensemble.py [""] # default: implement quicksort with pytest """ import asyncio diff --git a/examples/review_escalation.py b/examples/tutorial/review_escalation.py similarity index 96% rename from examples/review_escalation.py rename to examples/tutorial/review_escalation.py index c6062da..3c264c6 100644 --- a/examples/review_escalation.py +++ b/examples/tutorial/review_escalation.py @@ -6,7 +6,7 @@ for. Both seats are hired up front (enrollment is open-round-only), so the escalation path exists from the start whether or not it's taken. - python examples/review_escalation.py [""] # default: implement quicksort with pytest + python examples/tutorial/review_escalation.py [""] # default: implement quicksort with pytest """ import asyncio diff --git a/examples/tournament.py b/examples/tutorial/tournament.py similarity index 96% rename from examples/tournament.py rename to examples/tutorial/tournament.py index 806468e..6692987 100644 --- a/examples/tournament.py +++ b/examples/tutorial/tournament.py @@ -6,7 +6,7 @@ means a killed bracket resumes mid-tournament — finished matches replay their recorded verdicts instantly. - python examples/tournament.py [""] # default: implement quicksort with pytest + python examples/tutorial/tournament.py [""] # default: implement quicksort with pytest """ import asyncio diff --git a/src/h5i/orchestra/patterns.py b/src/h5i/orchestra/patterns.py index c3586d3..2597fe7 100644 --- a/src/h5i/orchestra/patterns.py +++ b/src/h5i/orchestra/patterns.py @@ -14,7 +14,7 @@ `merge_reviews`, `review_cycle`, `verify_and_judge`, `ask_with_valid_citations`, `render_evidence`, `mean_score_verdict`, `smaller_diff`. A custom pattern is an ordinary async function over these - (``examples/quorum_ensemble.py`` builds one from scratch). + (``examples/tutorial/quorum_ensemble.py`` builds one from scratch). - **Fork** — if the control flow itself doesn't fit, copy the pattern's ~40 lines into your score and edit. There is deliberately no plugin API to learn; patterns are user-space code. @@ -115,7 +115,7 @@ async def review_cycle( ``approve`` — revises once against the merged feedback. Returns updated artifacts without mutating ``latest``. For a quorum other than unanimity, write the loop yourself on `merge_reviews` — it is a few lines (see - ``examples/quorum_ensemble.py``).""" + ``examples/tutorial/quorum_ensemble.py``).""" pairs = [ (reviewer, target) for reviewer in agents diff --git a/tests/test_examples.py b/tests/test_examples.py index 4e46125..8e7bd4f 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -8,14 +8,18 @@ CHEAP_MODELS = {"claude-haiku-4-5", "gpt-5.4-mini"} -@pytest.mark.parametrize("path", sorted(EXAMPLES.glob("*.py")), ids=lambda p: p.name) +@pytest.mark.parametrize( + "path", + sorted(EXAMPLES.rglob("*.py")), + ids=lambda p: str(p.relative_to(EXAMPLES)), +) def test_examples_are_valid_python(path: Path): ast.parse(path.read_text(), filename=str(path)) @pytest.mark.parametrize("name", ["arena_score.py", "ensemble_score.py"]) def test_resident_examples_do_not_preflight_live_sessions(name: str): - tree = ast.parse((EXAMPLES / name).read_text()) + tree = ast.parse((EXAMPLES / "tutorial" / name).read_text()) live_checks = [ call for call in ast.walk(tree) @@ -31,7 +35,11 @@ def test_resident_examples_do_not_preflight_live_sessions(name: str): ) -@pytest.mark.parametrize("path", sorted(EXAMPLES.glob("*.py")), ids=lambda p: p.name) +@pytest.mark.parametrize( + "path", + sorted(EXAMPLES.rglob("*.py")), + ids=lambda p: str(p.relative_to(EXAMPLES)), +) def test_examples_pin_cheap_models(path: Path): tree = ast.parse(path.read_text()) hire_calls = [