feat(fase2): thread tools + budget middleware through fresh_workflow onto agents
This commit is contained in:
parent
bf0bbfffae
commit
434ecb92c9
2 changed files with 80 additions and 13 deletions
|
|
@ -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]])
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue