Skip to content
This repository was archived by the owner on Oct 19, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions fri_forecaster/pi_tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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()
Expand Down
30 changes: 28 additions & 2 deletions tests/test_pi_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Loading