From 434ecb92c93059ae5f9b47838b5fdd8aad1485ca Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 26 Jun 2026 00:36:06 +0200 Subject: [PATCH] feat(fase2): thread tools + budget middleware through fresh_workflow onto agents --- src/portfolio_optimiser/workflow.py | 50 +++++++++++++++++++++-------- tests/test_workflow.py | 43 +++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 13 deletions(-) diff --git a/src/portfolio_optimiser/workflow.py b/src/portfolio_optimiser/workflow.py index 9b9bf2b..f07dc62 100644 --- a/src/portfolio_optimiser/workflow.py +++ b/src/portfolio_optimiser/workflow.py @@ -42,10 +42,26 @@ def make_termination(n_turns: int) -> Callable[[Sequence[Message]], bool]: return terminate -def maker_checker_agents(client_factory: Callable[[str], BaseChatClient]) -> list[Agent]: - """FRESH proposer + checker agents, each backed by a FRESH client (zero cross-run state).""" +def maker_checker_agents( + client_factory: Callable[[str], BaseChatClient], + *, + tools: Sequence[Any] | None = None, + middleware: Sequence[Any] | None = None, +) -> list[Agent]: + """FRESH proposer + checker agents, each backed by a FRESH client (zero cross-run state). + + ``tools`` (the citation-bearing data-source tool, F7) and ``middleware`` (the budget + ``ChatMiddleware``, F2) are attached to EVERY agent so the debate reaches the data source + as a tool and is metered/short-circuited via the middleware. Both are constructed by the + orchestrator (``run_project``) and threaded through ``fresh_workflow``.""" return [ - Agent(client_factory(role), _INSTRUCTIONS[role], name=role) + Agent( + client_factory(role), + _INSTRUCTIONS[role], + name=role, + tools=tools, + middleware=middleware, + ) for role in _MAKER_CHECKER_ROLES ] @@ -55,12 +71,20 @@ def fresh_workflow( *, max_rounds: int = 3, enable_layer1_hitl: bool = False, + tools: Sequence[Any] | None = None, + middleware: Sequence[Any] | None = None, ) -> Any: """Build a FRESH maker-checker GroupChat with FRESH clients per call (B7). Bounded by ``with_max_rounds`` (B4) plus a higher turn-count termination safety net. ``client_factory`` is called once per role, so each run owns its own clients — no state survives between runs. + + ``tools`` + ``middleware`` are attached to each agent (F2/F7; constructed by the + orchestrator). ``output_from=[proposer]`` makes ``WorkflowRunResult.get_outputs()`` surface + the proposer's converged output — without it, ``get_outputs()`` yields only the + orchestrator's "reached max rounds" notice, so the F1 debate->generation dataflow could not + read the debate result (verified against installed 1.9.0). """ - agents = maker_checker_agents(client_factory) + agents = maker_checker_agents(client_factory, tools=tools, middleware=middleware) # Agents are built from _MAKER_CHECKER_ROLES in order with name=role, so the role tuple # IS the (typed, non-None) name list the selector cycles over. names: list[str] = list(_MAKER_CHECKER_ROLES) @@ -71,15 +95,15 @@ def fresh_workflow( counter["n"] += 1 return choice - builder = ( - GroupChatBuilder( - participants=agents, - selection_func=select, - # Safety net well above the hard cap; with_max_rounds is the binding bound (B4). - termination_condition=make_termination(max_rounds * len(names) + 1), - ) - .with_max_rounds(max_rounds) - ) + builder = GroupChatBuilder( + participants=agents, + selection_func=select, + # Safety net well above the hard cap; with_max_rounds is the binding bound (B4). + termination_condition=make_termination(max_rounds * len(names) + 1), + # Surface the PROPOSER's converged output so get_outputs() carries the debate + # result (F1); the default surfaces only the orchestrator's termination notice. + output_from=[agents[0]], + ).with_max_rounds(max_rounds) if enable_layer1_hitl: # Layer-1: in-run synchronous review on the checker (no checkpoint — research 01). builder = builder.with_request_info(agents=[agents[-1]]) diff --git a/tests/test_workflow.py b/tests/test_workflow.py index bf273b4..b01bfd0 100644 --- a/tests/test_workflow.py +++ b/tests/test_workflow.py @@ -57,3 +57,46 @@ def test_layer1_hitl_option_builds() -> None: wf = fresh_workflow(factory, max_rounds=2, enable_layer1_hitl=True) assert wf is not None + + +def test_tools_and_middleware_threaded_to_each_agent(monkeypatch) -> None: + """Construction-spy: every agent is built WITH the provided tools + middleware (F7 + F2 + wiring). `.tools` is not a public attr on a built Agent, so assert at the construction + boundary, not by reading the agent back.""" + from portfolio_optimiser import workflow as wf_mod + + recorded: list[dict[str, object]] = [] + + class _RecordingAgent: + def __init__(self, *args: object, **kwargs: object) -> None: + recorded.append(kwargs) + + monkeypatch.setattr(wf_mod, "Agent", _RecordingAgent) + + sentinel_tool = object() + sentinel_mw = object() + + def factory(role: str) -> BaseChatClient: + return FakeChatClient(default_reply="ok") + + wf_mod.maker_checker_agents(factory, tools=[sentinel_tool], middleware=[sentinel_mw]) + + assert len(recorded) == 2 # proposer + checker + for kwargs in recorded: + assert kwargs["tools"] == [sentinel_tool] + assert kwargs["middleware"] == [sentinel_mw] + + +async def test_output_from_surfaces_the_proposer_output() -> None: + """Behavioral: the debate surfaces the PROPOSER's converged output via output_from=[proposer] + — without it, get_outputs() yields only the orchestrator's 'reached max rounds' notice + (verified). Step 4's F1 fix consumes this.""" + marker = "PROPOSER_MARKER_7f3a" + + def factory(role: str) -> BaseChatClient: + return FakeChatClient(default_reply=marker if role == "proposer" else "checker view") + + wf = fresh_workflow(factory, max_rounds=2) + result = await wf.run("Find a cost-saving measure for FV42.") + texts = [getattr(o, "text", "") or "" for o in result.get_outputs()] + assert any(marker in t for t in texts), f"proposer output not surfaced; got {texts!r}"