Skip to content

Commit cdcee16

Browse files
authored
Agents: a 409 that is no cancellation fails the turn; an approval denied with 'no' says so (#304)
* Agents: a 409 that is no cancellation fails the turn, rather than stopping it A turn took any TerminalError with status 409 for Restate's cancellation, and closed as stopped: but a call fails with 409 for other reasons too. refund_graph.py's payout workflow, run a second time for an order, failed with 409 'the workflow method was already invoked', and the turn said 'Cancelled: the turn was stopped before it finished.'. is_cancellation takes Restate's cancellation error only: 409 'cancelled', which a stopped turn fails with too. The same for a Pydantic AI tool's terminal error, which the model now sees. * Tests: a stopped turn fails with Restate's cancellation error, 409 'cancelled' Three tests stood a TerminalError('stopped', 409) in for a stop. A stop, or Restate cancelling the invocation, fails with 409 'cancelled', which is_cancellation now tells from other 409s. * Pydantic AI: an approval denied with 'no' tells the model the call was denied Any text but an approving one denied the call, with that text as the reason: a manager's 'no' reached the model as the call's result, and it answered that 'the tool returned no'. A text that only says no ('no', 'denied', 'rejected'...) denies with Pydantic AI's own message, 'The tool call was denied.'; any other text is still the reason.
1 parent 56e0a86 commit cdcee16

6 files changed

Lines changed: 49 additions & 10 deletions

File tree

‎python/restate/agents/_common.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,16 @@ def check_window(window: Window) -> Window:
220220
CANCELLATION = 409
221221
"""The status code of Restate's cancellation error, which a stopped turn fails with."""
222222

223+
224+
def is_cancellation(error: BaseException) -> bool:
225+
"""Return whether ``error`` is Restate's cancellation error, which a stopped turn fails with too.
226+
227+
Its status code alone does not tell: a call fails with 409 for other reasons, such as a workflow run a second
228+
time, which fail the turn rather than stop it.
229+
"""
230+
return isinstance(error, TerminalError) and error.status_code == CANCELLATION and error.message == "cancelled"
231+
232+
223233
CANCELLED = "Cancelled: the turn was stopped before it finished."
224234
"""The answer of a cancelled turn, and the result of each tool call it cut off, which the model sees later in the conversation."""
225235

‎python/restate/agents/_conversation.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,6 @@
9494
from restate.agents._calls import outcomes
9595
from restate.agents._common import (
9696
BUDGET,
97-
CANCELLATION,
9897
CANCELLED,
9998
ENDED,
10099
SUMMARIZE,
@@ -103,6 +102,7 @@
103102
Window,
104103
check_window,
105104
cut_off_result,
105+
is_cancellation,
106106
next_prompt_id,
107107
refusal_of,
108108
stored_answer,
@@ -1402,7 +1402,7 @@ async def answering() -> str:
14021402
# The attempt ended: stop here, and let the next attempt carry on the run.
14031403
raise
14041404
except TerminalError as error:
1405-
if error.status_code == CANCELLATION:
1405+
if is_cancellation(error):
14061406
# Cancelled: close the turn, so that the next prompt starts afresh.
14071407
await turn.close(ctx, "cancelled", CANCELLED)
14081408
await conversation.seal()

‎python/restate/agents/pydantic_ai/_agent.py‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,6 @@
144144
)
145145
from pydantic_ai import Tool as PydanticTool
146146
from restate.agents._common import (
147-
CANCELLATION,
148147
Check,
149148
CompactHistory,
150149
ErrorSet,
@@ -154,6 +153,7 @@
154153
check_own,
155154
configured,
156155
from_tool,
156+
is_cancellation,
157157
model_of,
158158
provider_of,
159159
)
@@ -222,6 +222,9 @@ async def run(run_ctx: RunContext[Any], **args: Any) -> str:
222222
_APPROVED = frozenset({"approved", "approve", "yes", "ok", "allow", "allowed"})
223223
"""The texts that approve a tool call, whatever their case; any other text denies it, with that reason."""
224224

225+
_DENIED = frozenset({"", "denied", "deny", "no", "rejected", "reject", "declined", "decline", "refused", "refuse"})
226+
"""The texts that deny a tool call with no reason of their own, whatever their case: the model is told it was denied."""
227+
225228

226229
def approval_of(value: Any) -> ToolApproved | ToolDenied:
227230
"""Return what the value of an ``approval`` signal decides about its tool call.
@@ -230,12 +233,16 @@ def approval_of(value: Any) -> ToolApproved | ToolDenied:
230233
``{"approved": true, "args": {...}}`` approves it with other arguments.
231234
``false`` or ``{"approved": false, "reason": "..."}`` denies it, and so
232235
does any other text, which is the denial's reason for the model: a value
233-
that does not say "approved" never runs the tool.
236+
that does not say "approved" never runs the tool. A text that only says
237+
"no" (``"denied"``, ``"rejected"``...) tells the model the call was denied.
234238
"""
235239
if value is True:
236240
return ToolApproved()
237241
if isinstance(value, str):
238-
return ToolApproved() if value.strip().lower() in _APPROVED else ToolDenied(value)
242+
said = value.strip().lower()
243+
if said in _APPROVED:
244+
return ToolApproved()
245+
return ToolDenied() if said in _DENIED else ToolDenied(value)
239246
if isinstance(value, dict):
240247
decision = typing.cast(dict[str, Any], value)
241248
if decision.get("approved") is True:
@@ -435,7 +442,7 @@ async def on_tool_execute_error(
435442
stop = stop_signal_of(self.ctx)
436443
if stop is not None and stop.reason is not None:
437444
raise stop.reason from error
438-
if isinstance(error, TerminalError) and error.status_code != CANCELLATION:
445+
if isinstance(error, TerminalError) and not is_cancellation(error):
439446
await tool_call_ended(self.ctx)
440447
return f"Error: {error.message}"
441448
# Mark the error as the tool's, so that it is never mistaken for the model provider refusing.

‎tests/agents_conversation.py‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,15 +174,35 @@ async def run(conversation: Conversation) -> str:
174174
]
175175

176176

177+
async def test_a_409_that_is_no_cancellation_fails_the_turn_rather_than_cancelling_it():
178+
"""A call that fails with 409 for another reason (a workflow run a second time) fails the turn with its error."""
179+
seen: list[list[Item]] = []
180+
181+
async def run(conversation: Conversation) -> str:
182+
seen.append([entry.item for entry in conversation.entries])
183+
if len(seen) == 1:
184+
raise restate.TerminalError("the workflow method was already invoked", status_code=409)
185+
return "fine"
186+
187+
ask = asking(run)
188+
with pytest.raises(restate.TerminalError, match="already invoked"):
189+
await ask("first")
190+
assert await ask("second") == "fine"
191+
assert seen[1][-2:] == [
192+
{"role": "assistant", "text": "Error: the workflow method was already invoked"},
193+
{"role": "user", "text": "second"},
194+
]
195+
196+
177197
async def test_a_stopped_run_stores_nothing_more():
178198
kept: list[Conversation] = []
179199

180200
async def run(conversation: Conversation) -> str:
181201
kept.append(conversation)
182-
raise restate.TerminalError("stopped", status_code=409)
202+
raise restate.TerminalError("cancelled", status_code=409)
183203

184204
ask = asking(run)
185-
with pytest.raises(restate.TerminalError, match="stopped"):
205+
with pytest.raises(restate.TerminalError, match="cancelled"):
186206
await ask("first")
187207
[conversation] = kept
188208
turn = conversation.turn

‎tests/agents_pydantic_ai.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -835,7 +835,7 @@ class StopBeforeTheNextRequest(AbstractCapability[Any]):
835835

836836
async def before_model_request(self, ctx: RunContext[Any], request_context: Any) -> Any:
837837
if any(isinstance(p, ToolReturnPart) for m in ctx.messages if isinstance(m, ModelRequest) for p in m.parts):
838-
raise restate.TerminalError("stopped", status_code=409)
838+
raise restate.TerminalError("cancelled", status_code=409)
839839
return request_context
840840

841841

@@ -975,6 +975,8 @@ def test_an_approvals_signal_approves_only_what_says_so():
975975
assert approval_of(approving) == ToolApproved()
976976
assert approval_of({"approved": True, "args": {"city": "Oslo"}}) == ToolApproved(override_args={"city": "Oslo"})
977977
assert approval_of(False) == ToolDenied()
978+
for denying in ("no", "Denied", " rejected ", ""): # no reason of their own: the model is told it was denied
979+
assert approval_of(denying) == ToolDenied()
978980
assert approval_of("not today") == ToolDenied("not today")
979981
assert approval_of({"approved": False, "reason": "too much"}) == ToolDenied("too much")
980982
for anything in (None, 1, ["yes"], {"approved": "yes"}):

‎tests/agents_snapshots.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,7 @@ async def run(conversation: Conversation) -> str:
459459
await conversation.turn.append_reply(_calling("c1"))
460460
await wait(contexts[-1])
461461
# The turn is stopped before the call's result is stored.
462-
raise TerminalError("stopped", status_code=409)
462+
raise TerminalError("cancelled", status_code=409)
463463

464464
@obj.handler
465465
async def ask(ctx: sessions.SessionContext, text: str) -> str:

0 commit comments

Comments
 (0)