/trekreview flagged the Spike B(b) fan-out experiment as BROKEN_SUCCESS_CRITERION (BLOCKER): it asserted a per-client call_count reached 3 on a reused instance vs 1 on a fresh one — a tautology true for any un-reset mutable counter, independent of MAF, that never exercised the real G2/B7 shared-Workflow state-corruption footgun. It was a false-confirm of a de-risk assumption. Rebuilt to observe genuine MAF thread state via the messages each participant RECEIVES (new FakeChatClient.received_texts seam): - shared_instance_conversation_bleed: a reused built ConcurrentBuilder Workflow accumulates the conversation across .run() calls — run N's participants receive runs 0..N-1's prompts/replies (measured [[p0],[p0,p1],[p0,p1,p2]], strictly monotonic) => genuine cross-run contamination. - fresh_instance_conversation_isolation: a fresh instance per run gives each a clean thread => each participant sees only its own project ([[p0],[p1],[p2]]). Assumption now CONFIRMED with a meaningful observable. findings-b.md gains a Method note recording why it was rebuilt; README rows updated. Also fixes the MINOR: a_groupchat.run_live now mkdirs the findings dir before write_text so a post-disposal run does not lose the measured result. Gate green: ruff check + format, mypy src, pytest 48 passed / 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fif1r1En5W542HbZV88yMH
146 lines
6.9 KiB
Python
146 lines
6.9 KiB
Python
"""Spike B — Magentic unbounded-termination footgun (G1/B4) + fan-out state
|
|
isolation (G2/B7).
|
|
|
|
Driving the GA builders with the fake client (de-risked by the Step 2 smoke) *is*
|
|
the experiment here — no live LLM, so this whole spike runs in the quality gate.
|
|
|
|
(a) **Magentic unbounded (G1/B4).** A `StandardMagenticManager` whose progress ledger
|
|
always says "not satisfied / progress being made" never finalizes. With
|
|
`max_round_count=None` it would loop forever; we drive it under the shared harness
|
|
round/iteration guard and confirm the guard is what stops it (it does NOT
|
|
self-terminate). With an explicit `max_round_count` it terminates cleanly.
|
|
|
|
(b) **Fan-out state isolation (G2/B7).** Reusing ONE built `ConcurrentBuilder` workflow
|
|
across the three reference projects bleeds *conversation state*: MAF accumulates the
|
|
shared thread across `.run()` calls, so each run's participants receive the PRIOR
|
|
projects' prompts and replies (project N contaminates N+1's context). A FRESH instance
|
|
per run — via `fresh_workflow()` — gives each run a clean thread (zero contamination).
|
|
The observable is the message history each participant *receives*, captured via
|
|
`FakeChatClient.received_texts` — NOT a call counter (a counter would rise for any
|
|
reused mutable object and prove nothing about MAF state).
|
|
|
|
Token use: 0 — no live LLM (the fake client's "tokens" are word-counts of canned replies).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import warnings
|
|
|
|
from agent_framework import Agent
|
|
from agent_framework.orchestrations import (
|
|
ConcurrentBuilder,
|
|
MagenticBuilder,
|
|
StandardMagenticManager,
|
|
)
|
|
|
|
from spikes._harness import Budget, BudgetExceeded, FakeChatClient, TokenMeter, fake_agent
|
|
|
|
# Cutting an *instrumented* Magentic stream short (the only way to observe an unbounded
|
|
# run) makes OpenTelemetry log a benign "Failed to detach context" on generator close.
|
|
# It is cosmetic — silence it so the spike output stays readable. (Recorded in findings-b.)
|
|
logging.getLogger("opentelemetry.context").setLevel(logging.CRITICAL)
|
|
|
|
# A progress ledger that never reports satisfaction and always claims progress — so the
|
|
# manager has no natural reason to stop. next_speaker points at the single worker.
|
|
_NEVER_SATISFIED_LEDGER = json.dumps(
|
|
{
|
|
"is_request_satisfied": {"reason": "not yet", "answer": False},
|
|
"is_in_loop": {"reason": "no", "answer": False},
|
|
"is_progress_being_made": {"reason": "yes", "answer": True},
|
|
"next_speaker": {"reason": "worker should act", "answer": "worker"},
|
|
"instruction_or_question": {"reason": "continue", "answer": "Keep working."},
|
|
}
|
|
)
|
|
|
|
|
|
def _magentic(max_round_count: int | None) -> object:
|
|
"""Build a Magentic workflow whose manager never self-finalizes."""
|
|
manager = StandardMagenticManager(
|
|
agent=Agent(
|
|
FakeChatClient(default_reply=_NEVER_SATISFIED_LEDGER), "manager", name="manager"
|
|
),
|
|
max_round_count=max_round_count,
|
|
)
|
|
worker = Agent(FakeChatClient(default_reply="worker did some work"), "worker", name="worker")
|
|
return MagenticBuilder(participants=[worker], manager=manager).build()
|
|
|
|
|
|
async def unbounded_magentic_self_terminates(*, guard_rounds: int = 10) -> bool:
|
|
"""Drive an unbounded (`max_round_count=None`) Magentic under the harness guard.
|
|
|
|
Returns ``True`` if the workflow stopped on its own, ``False`` if the external guard
|
|
had to stop it. The footgun (G1/B4) is confirmed when this returns ``False``.
|
|
"""
|
|
workflow = _magentic(None)
|
|
meter = TokenMeter(Budget(max_tokens=10**9, max_rounds=guard_rounds))
|
|
guard_fired = False
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", RuntimeWarning)
|
|
async for _event in workflow.run("Do a never-ending task.", stream=True):
|
|
try:
|
|
meter.tick_round() # one tick per orchestration event (iteration guard)
|
|
except BudgetExceeded:
|
|
guard_fired = True
|
|
break
|
|
return not guard_fired
|
|
|
|
|
|
async def bounded_magentic_terminates(*, max_round_count: int = 2) -> bool:
|
|
"""An explicit `max_round_count` makes the same never-satisfied manager stop cleanly.
|
|
|
|
Returns ``True`` when the workflow runs to completion and yields an output.
|
|
"""
|
|
workflow = _magentic(max_round_count)
|
|
result = await workflow.run("Do a bounded task.")
|
|
return len(result.get_outputs()) >= 1
|
|
|
|
|
|
def fresh_workflow() -> tuple[object, tuple[FakeChatClient, FakeChatClient]]:
|
|
"""B7 mitigation: a factory that builds a FRESH fan-out workflow with FRESH clients
|
|
every call, so no state survives between runs."""
|
|
c1 = FakeChatClient(default_reply="participant one view")
|
|
c2 = FakeChatClient(default_reply="participant two view")
|
|
workflow = ConcurrentBuilder(participants=[fake_agent(c1, "w1"), fake_agent(c2, "w2")]).build()
|
|
return workflow, (c1, c2)
|
|
|
|
|
|
def _projects_seen(received_texts: list[str], project_ids: list[str]) -> list[str]:
|
|
"""Which project ids appear anywhere in the messages an agent received on one call.
|
|
|
|
The observable for genuine state bleed: if a later run's agent sees an EARLIER
|
|
project's id, the workflow carried that project's conversation forward."""
|
|
blob = " ".join(received_texts)
|
|
return [pid for pid in project_ids if pid in blob]
|
|
|
|
|
|
async def shared_instance_conversation_bleed(project_ids: list[str]) -> list[list[str]]:
|
|
"""Reuse ONE built fan-out workflow across every project. MAF accumulates the shared
|
|
thread across `.run()` calls, so run N's participants also receive runs 0..N-1's
|
|
prompts/replies — genuine cross-run state corruption (G2/B7).
|
|
|
|
Returns, per run (in order), which project ids were visible to a participant on that
|
|
run. With a reused instance this grows monotonically: ``[[p0], [p0, p1], [p0, p1, p2]]``."""
|
|
workflow, clients = fresh_workflow()
|
|
for pid in project_ids:
|
|
await workflow.run(f"Evaluate project {pid}.")
|
|
# One participant is representative: concurrent fan-out feeds every participant the
|
|
# same accumulated thread. clients[0] was called once per run, in order.
|
|
rep = clients[0]
|
|
return [_projects_seen(call_view, project_ids) for call_view in rep.received_texts]
|
|
|
|
|
|
async def fresh_instance_conversation_isolation(project_ids: list[str]) -> list[list[str]]:
|
|
"""A FRESH instance per project (the B7 mitigation): each run gets a clean thread, so
|
|
a participant sees ONLY its own project — zero cross-run contamination.
|
|
|
|
Returns, per run, the project ids visible to a participant; each should be exactly
|
|
its own: ``[[p0], [p1], [p2]]``."""
|
|
seen_per_run: list[list[str]] = []
|
|
for pid in project_ids:
|
|
workflow, clients = fresh_workflow()
|
|
await workflow.run(f"Evaluate project {pid}.")
|
|
# fresh client -> exactly one call this run; read its single received view.
|
|
seen_per_run.append(_projects_seen(clients[0].received_texts[0], project_ids))
|
|
return seen_per_run
|