feat(fase2): thread tools + budget middleware through fresh_workflow onto agents

This commit is contained in:
Kjell Tore Guttormsen 2026-06-26 00:36:06 +02:00
commit 434ecb92c9
2 changed files with 80 additions and 13 deletions

View file

@ -42,10 +42,26 @@ def make_termination(n_turns: int) -> Callable[[Sequence[Message]], bool]:
return terminate return terminate
def maker_checker_agents(client_factory: Callable[[str], BaseChatClient]) -> list[Agent]: def maker_checker_agents(
"""FRESH proposer + checker agents, each backed by a FRESH client (zero cross-run state).""" 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 [ 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 for role in _MAKER_CHECKER_ROLES
] ]
@ -55,12 +71,20 @@ def fresh_workflow(
*, *,
max_rounds: int = 3, max_rounds: int = 3,
enable_layer1_hitl: bool = False, enable_layer1_hitl: bool = False,
tools: Sequence[Any] | None = None,
middleware: Sequence[Any] | None = None,
) -> Any: ) -> Any:
"""Build a FRESH maker-checker GroupChat with FRESH clients per call (B7). Bounded by """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`` ``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. 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 # 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. # IS the (typed, non-None) name list the selector cycles over.
names: list[str] = list(_MAKER_CHECKER_ROLES) names: list[str] = list(_MAKER_CHECKER_ROLES)
@ -71,15 +95,15 @@ def fresh_workflow(
counter["n"] += 1 counter["n"] += 1
return choice return choice
builder = ( builder = GroupChatBuilder(
GroupChatBuilder( participants=agents,
participants=agents, selection_func=select,
selection_func=select, # Safety net well above the hard cap; with_max_rounds is the binding bound (B4).
# Safety net well above the hard cap; with_max_rounds is the binding bound (B4). termination_condition=make_termination(max_rounds * len(names) + 1),
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.
.with_max_rounds(max_rounds) output_from=[agents[0]],
) ).with_max_rounds(max_rounds)
if enable_layer1_hitl: if enable_layer1_hitl:
# Layer-1: in-run synchronous review on the checker (no checkpoint — research 01). # Layer-1: in-run synchronous review on the checker (no checkpoint — research 01).
builder = builder.with_request_info(agents=[agents[-1]]) builder = builder.with_request_info(agents=[agents[-1]])

View file

@ -57,3 +57,46 @@ def test_layer1_hitl_option_builds() -> None:
wf = fresh_workflow(factory, max_rounds=2, enable_layer1_hitl=True) wf = fresh_workflow(factory, max_rounds=2, enable_layer1_hitl=True)
assert wf is not None 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}"