diff --git a/fri_forecaster/pi_tools.ts b/fri_forecaster/pi_tools.ts index c077453..74284a8 100644 --- a/fri_forecaster/pi_tools.ts +++ b/fri_forecaster/pi_tools.ts @@ -260,9 +260,10 @@ export default function (pi: ExtensionAPI) { pi.setActiveTools([]); return; } - return { entries: [say(called - ? `Call ${debrief.tool.name} again with what is still missing or wrong (entries you already sent are kept), fixing: ${d.problems.join("; ").slice(0, 4000)}` - : `Now call ${debrief.tool.name} with the debrief.`)], continue: true }; + // Not called (prose, or a refusal, which cannot continue from here): the + // run is settling, and agent_before_settle asks again. + if (!called) return; + return { entries: [say(`Call ${debrief.tool.name} again with what is still missing or wrong (entries you already sent are kept), fixing: ${d.problems.join("; ").slice(0, 4000)}`)], continue: true }; } if (phase === "forecast" && maxTurns) { if (turns >= limit) { pi.setActiveTools([]); return; } @@ -274,8 +275,10 @@ export default function (pi: ExtensionAPI) { } }); - // A reply in prose (no tool call) ends pi's run; under max_turns, nudge instead. + // A reply in prose (no tool call, or a provider refusal) ends pi's run; in the + // debrief, and under max_turns, nudge instead. pi.on("agent_before_settle", async () => { + if (phase === "debrief") return { entries: [say(`Now call ${debrief.tool.name} with the debrief.`)], continue: true }; if (phase !== "forecast" || finalDone || !maxTurns || turns >= limit || forcing) return; const missingCover = uncovered(); return { entries: [say(unlocked() diff --git a/tests/test_pi_tools.py b/tests/test_pi_tools.py index f76eeee..f83c4a9 100644 --- a/tests/test_pi_tools.py +++ b/tests/test_pi_tools.py @@ -34,9 +34,13 @@ VALIDATOR = "def check(value, need):\n return [f'{k}: missing' for k in need if k not in (value or {})]\n" +REFUSAL = object() + + class ScriptedModel: """An OpenAI chat-completions endpoint that plays a script: one tool call per - turn, then a closing text. It keeps every request, so a test can read the + turn, then a closing text; REFUSAL ends a turn the way a provider's refusal + does (pi reads it as an error). It keeps every request, so a test can read the tool result pi_tools.ts returned for each call.""" def __init__(self, script): @@ -65,7 +69,9 @@ def do_POST(self): @staticmethod def chunks(turn, n): base = {"id": f"c{n}", "object": "chat.completion.chunk", "created": 0, "model": "scripted"} - if isinstance(turn, str): + if turn is REFUSAL: + delta, finish = {"role": "assistant", "content": ""}, "content_filter" + elif isinstance(turn, str): delta, finish = {"role": "assistant", "content": turn}, "stop" else: name, args = turn @@ -263,6 +269,26 @@ def test_debrief_merges_and_retries(self): self.assertEqual(res["debrief"], {"result": {"a": "why a", "b": "why b"}, "complete": True, "attempts": 2, "problems": [], "error": None}) + def test_debrief_asks_again_after_a_refusal(self): + # A refused turn ends pi's run unless something asks again. + debrief = {"tool": {"name": "submit_debrief", "description": "d", + "parameters": {"type": "object", "properties": {"a": {"type": "string"}}}}, + "prompt": "Debrief now.", "retries": 1} + res, model = self.run_pi([ + ("web_search", {"query": "q1"}), + ("web_search", {"query": "q2"}), + ("submit_forecast", {"forecasts": [{"k": "a", "probability": 0.1}, {"k": "b", "probability": 0.2}], + "rationale": "r"}), + REFUSAL, + ("submit_debrief", {"a": "why a"}), + "done", + ], debrief=debrief) + self.assertEqual(res["rc"], 0, open(os.path.join(res["workdir"], "stderr.txt")).read()) + self.assertEqual(model.last_user(5), "Now call submit_debrief with the debrief.") + self.assertEqual(model.tools(5), {"submit_debrief"}) + self.assertEqual(res["debrief"], {"result": {"a": "why a"}, "complete": True, + "attempts": 2, "problems": [], "error": None}) + if __name__ == "__main__": unittest.main()