Skip to content

49 · Why a turn was declined

A plan is declined when a turn asks for something the catalogue gives no safe way to do: data sent where no tool sends it, a write fed by a calculation, data another agent reads. The plan names the rule in declined_because, and an agent says it in its reply. This chapter is each rule, what sets it off, and what clears it -- and the one refusal that is not a rule of the plan but says what to declare.

Language support

The selector reads turns written in English and in Brazilian Portuguese (pt-BR). The language is chosen, never guessed from the turn: the console's Settings page (Language the selector reads), language on a plan request, or the settings.language of your vocabulary file; with none of them, English. A Portuguese turn may be typed with its accents or without them. European Portuguese is not a grammar the selector has, and a turn in any other language is unread.

The four rules

Rule Declines a turn that What clears it
S1 egress uses a verb that sends data out -- export, send, upload, post -- that no tool the turn reaches takes a tool that sends, whose own words include the verb: sends_out: true on it (chapter 48)
S2 destination names an e-mail address, a URL, an IP address, a file path or a host that no argument of the tool took, or a place (a webhook, a bucket) after to, on or into that no tool holds a parameter that takes the value, so the router binds it; or subject_argument where the address says whose data is read (below)
S3 write computes a value and writes it: the calculation's result goes to a write, or the data a calculation reads is a write's the calculation as its own turn, and the write as the next, with the person's confirmation
S4 scope asks for a calculation over data a tool of another agent names better than any tool of this one ask the agent that reads the data; visibility is never widened by a calculation

The rule's name comes first, then what set it off: S1 egress: export, S2 destination: ana@example.com. Where several rules hold, declined_because names each, separated by ;. One declined step declines the whole plan: no step of it runs, not even the harmless ones around it, and it is never handed to a model, whatever your setting for unread turns (chapter 22).

The shop's desk (chapter 30), asked three things it gives no way to do:

with Client() as client:
    for turn in ["export order 4411",
                 "send the receipt for order 4411 to ana@example.com",
                 "get order 4411, compute the total plus 10%, then charge it"]:
        plan = client.session("desk").read(turn)
        print(f"{turn}\n  {plan.outcome} | {plan.declined_because}")
export order 4411
  declined | S1 egress: export
send the receipt for order 4411 to ana@example.com
  declined | S2 destination: ana@example.com
get order 4411, compute the total plus 10%, then charge it
  declined | S3 write: charge_card writes what step 1 computed

The desk has no tool that exports; send_receipt sends the receipt to the order's customer and takes no address, so the address is a destination nothing declared; and charge_card would charge an amount a calculation produced.

S4: data another agent reads

Two agents, the desk and the payroll, each with its own tools:

Path("staff_tools.py").write_text('''
def get_shift(person: str) -> dict:
    """Show a person's shift this week."""
    return {"person": person, "shift": "Mon-Fri 08:00"}


def get_salaries() -> list:
    """List every salary on the payroll."""
    return [{"person": "Ana", "salary": 3100}, {"person": "Rui", "salary": 2900}]
''', encoding="utf-8")
Path("staff.yaml").write_text("""\
agents:
  desk:
    tools: [get_shift]
  payroll:
    privilege: high
    tools: [get_salaries]
tools:
  - python: staff_tools
""", encoding="utf-8")

Asked of the desk, a calculation over the payroll:

desk = Agent.from_file("staff.yaml", name="desk")
print(desk.chat("what is the average salary on the payroll", user="u-1").text)
I can't do that: S4 scope: the data is not this agent's to read. (rule S4 scope: the data is not this agent's to read)

The salaries are named by get_salaries, which only the payroll agent may call. The calculation is declined rather than run over whatever the desk can read.

What an agent says

An agent made with Agent or build_stalwart_graph answers a declined plan with one sentence and the rule, I can't do that: <declined_because>. (rule <rule>), and runs nothing. The graph's state keeps the same refusal as data, {"sentence": ..., "rule": ...}, so an interface can show it as a refusal without reading the sentence. With client.session(...) you have the plan itself, and say what you like.

An address that says whose data is read

A tool that reads one account's data -- a calendar, a mailbox -- often takes the account as an e-mail address. The address in that argument is not a destination: nothing is sent to it. The tool check cannot tell that from the tool's schema, and refuses the call where it cannot: an address in an argument of a tool that is not declared sends_out is undeclared_destination (chapter 32), whose sentence says what to declare.

A calendar tool, in three files: one that says nothing, one that gives the account in defaults:, and one that names the argument as the tool's subject_argument:

FILES = {
    "asks.yaml": "",
    "given.yaml": "    defaults: {user_email: ana@example.com}\n",
    "subject.yaml": "    defaults: {user_email: ana@example.com}\n",
}
for name, defaults in FILES.items():
    words = "  - name: list_events\n    subject_argument: user_email\n" if name == "subject.yaml" else ""
    Path(name).write_text("agents:\n  assistant:\n    tools: [list_events]\n" + defaults
                          + "tools:\n  - python: calendar_tools\n" + words, encoding="utf-8")
for name in FILES:
    bot = Agent.from_file(name)
    for turn in ["list my events", "list my events and send them to bob@example.com"]:
        print(f"{name} | {turn}\n  {bot.chat(turn, user='u-1').text}")
asks.yaml | list my events
  To list events, which user email?
asks.yaml | list my events and send them to bob@example.com
  I can't do that: S1 egress: send. (rule S1 egress: send)
given.yaml | list my events
  list_events was given an address to send to, and it is not a tool that sends anything out. Where it takes a recipient, declare the tool sends_out; where the address only says whose data it reads, such as the account's own, name that argument as the tool's subject_argument.
given.yaml | list my events and send them to bob@example.com
  I can't do that: S1 egress: send. (rule S1 egress: send)
subject.yaml | list my events
  {"account": "ana@example.com", "events": ["09:00 stand-up"]}
subject.yaml | list my events and send them to bob@example.com
  I can't do that: S1 egress: send; S2 destination: bob@example.com. (rule S1 egress: send; S2 destination: bob@example.com)
  • asks.yaml: the router asks for the account, since nothing gives it.
  • given.yaml: defaults: gives the account to every call, so the router no longer asks (chapter 48). The call is refused by the tool check, which reads the address as one the calendar would send to.
  • subject.yaml: subject_argument: user_email says the address selects whose calendar is read. The call runs, and the events come back.

In each, send them to bob@example.com is declined: no tool sends (S1). With subject_argument, Bob's address is a destination too (S2): an address said as where something goes is never taken as whose data is read, even where the subject's argument could hold it.

sends_out: true is the other answer, for a tool whose address argument is a recipient: a tool that sends e-mail. Declare whichever is true of the tool. A tool declared sends_out is a way out of the agent to the bug hunter, which finds every route by which confidential data reaches one unconfirmed (QG019), so declare it only where the tool sends.

A word you add to a tool in agent.yaml is registered with the catalogue before the agent's next run: the tool check enforces the file as it is.

Turns a tool's description reaches

The router reads a turn against each tool's own words: its name, the words you declare, and also_called, the words a person uses for what the tool acts on (chapter 48). Where none of those reaches a tool, the words of the tools' descriptions are read: a turn that names what one description alone speaks of is read as that tool's.

with Client() as client:
    for turn in ["who is the customer of 4411", "what is the newest support message"]:
        plan = client.session("desk").read(turn)
        step = plan.steps[0]
        print(f"{turn}\n  {plan.outcome} | {step.selection.tool} | {step.decided_by}")
who is the customer of 4411
  run | get_order | tool: derived
what is the newest support message
  run | read_inbox | tool: derived

Customer is in no tool's name; get_order's description says it shows the customer, and no other tool's does. The rule is tool: derived. A write reached this way is never called on the description's word: the plan asks the person to confirm it first.

So a tool's description is part of how it is reached. The descriptions are yours: a tool from your code takes its docstring's first line, one from an MCP server or an OpenAPI document the description the server or document gives, and description: in agent.yaml replaces either. A changed description changes the catalogue, so stalwart verify registers a new version and the change shows in its report. Where a word of a description reaches the wrong tool, say the word in also_called on the right one: the tools' own words are read before any description. For the tools you maintain yourself, a description that says what that tool alone does, in words the other descriptions do not share, makes the catalogue less ambiguous and every turn read against it more accurate.

Reading declined_because yourself

  1. Read the turn with client.session(agent).read(turn) and print plan.declined_because and each step's decided_by: the rule and what set it off.
  2. For S1, find the tool that should send. If there is one, declare it sends_out and make sure its name or also_called carries the verb the person uses. If there is none, the refusal is the answer.
  3. For S2, look at the value named. If it is an argument the tool should take, check that the tool has a parameter for it, so the router binds it. If it says whose data is read, declare subject_argument.
  4. For S3 and S4, the turn asks for more than one safe step: let the person ask for the calculation and the write separately, or ask the agent that reads the data.

The limits

  • The rules read the catalogue and the turn, nothing else. A tool that sends but does not say so is read as one that does not; a parameter that takes an address but is not declared is read as no parameter.
  • A declined plan is never handed to a model, whatever your setting for unread turns: a turn the router understands to be unsafe is not one a model gets to answer.
  • subject_argument and sends_out are your statements about a tool. The checks hold the tool to them; they do not find out whether they are true.