---
name: agent_escalation_design
description: "Design the escalation path for a working agent: trigger conditions for stopping, severity tiers, the fields a complete escalation packet carries, routing, waiting behavior, and resume conditions, with a check script that validates a packet before it is sent. Use when an agent run needs a defined way to stop and hand off when blocked, uncertain, or facing harm or cost beyond its brief."
metadata: { "includeInPrompt": true }
---

# Agent Escalation Design

## Purpose

Design an escalation path before a run needs one: the conditions that stop work, the severity tier that sets the response, the packet that carries the case, the recipient that receives it, the behavior while waiting, and the condition that allows work to resume. Without a path set in advance, a blocked run either pushes ahead past its brief or stops with a note too thin for anyone to act on. This skill turns the stop into a checked handoff with named fields and a pass or fail result from a fixed check routine.

Scope:
- human-in-the-loop-design covers planned pre-action approval checkpoints, set before an action runs. This skill covers unplanned mid-run exceptions where the agent is blocked, uncertain, or facing harm or cost beyond its brief.
- autonomous-loop-ops covers operating long-running loops from outside, including their stall monitoring. This skill covers escalation design for a run in general, from inside the run, whether the run is a loop or a single task.
- escalation-brief covers writing a one-page brief from a customer support thread. This skill is not support-specific, and its packet is for any blocked agent task.
- This skill does not cover incident response for a live service, paging schedules, or staffing a review queue. The deliverable is an escalation design plus a checked packet. The channel that carries the packet and the system that stores it are local choices, recorded in the run record.
- Tool claims in this skill were checked on 2026-10-16 by running the inlined check script with Python 3.12.3, which is the version `python3 --version` returned on the drafting machine. The script uses only the Python standard library (`argparse`, `json`, and `sys`), so no package version is quoted and no install step is needed. Every number in the Output Contract and in the expected output blocks comes from a real run of that script against its fixtures.

## Workflow

1. Write the trigger conditions before the run starts.
   - Use only eight trigger types, with fixed meanings:
     - `blocked`: a required input, tool, or record is unavailable and no workaround inside the brief exists.
     - `uncertain`: two or more readings of the task or the data remain plausible after checking, and picking wrong carries real cost.
     - `harm_risk`: the next step could damage data, property, accounts, or third parties, or could reverse work that is hard to undo.
     - `cost_risk`: the next step would spend money, quota, or time past the limit stated in the brief.
     - `permission_denied`: access the task needs was refused, expired, or was never granted.
     - `conflicting_instructions`: two instructions in force point at different actions and no priority rule in the brief settles them.
     - `missing_input`: a fact the task depends on is absent and cannot be derived from records the run can read.
     - `tool_failure`: a tool fails repeatedly in a way that changes the result, after at least one retry with a different approach.
   - For each trigger type that applies to the task, write the concrete test the run will use, for example a count difference, a spend figure, or a named error seen twice. A trigger with no test depends on a feeling at run time and will fire late or not at all.
   - Write the triggers the run must never escalate on as well: routine cases the brief already decides, recoverable errors with a stated retry, and questions the run can answer from its own records. Escalating routine cases trains the recipient to ignore packets.
   - Record a retry limit per trigger where a retry makes sense. One different approach after a first failure is a plain default. After the limit, the trigger fires and the packet records what was tried.

2. Set the severity tiers and what each tier changes.
   - Use only four tiers, with fixed meanings:
     - `low`: work can continue on other parts of the task, and a decision is needed before the blocked part is finished.
     - `medium`: the blocked part cannot finish without a decision, and other parts may continue under the waiting rules in step 8.
     - `high`: the whole run stops, because continuing any part risks building on a wrong or unsafe base.
     - `critical`: the run stops at once and any partial action in progress is made safe first, because harm, loss, or an irreversible step is close.
   - Tie each tier to three settings and write them in the design: how fast a response is needed, stated as a plain time window in the design record; which recipient receives the packet; and which waiting behavior applies from step 8.
   - Assign severity from the consequence of acting without a decision, not from how stuck the run feels. A run that is fully stuck on a low consequence choice is `medium` at most. A run that could still act, where acting wrong is costly, is `high` or `critical`.
   - A `critical` packet requires waiting behavior `paused` or `stopped`. The check script enforces this rule, because a critical case that keeps doing read or subset work has no clear stop line.

3. Stop safely before writing the packet.
   - Finish or roll back the step in progress to a stable point: a saved file, a recorded partial count, a closed transaction, or a stated halfway state with its exact position. Never escalate from a state that only exists in working memory.
   - Stop any scheduled or repeated action the run started that would keep acting while the case is open, and record that it was stopped. A packet about a blocked step loses force if a retry loop keeps firing behind it.
   - Write down the last verified good state: what was checked, when it was checked, and the record that proves it. The resume step in step 10 starts from this state, not from the point of failure.
   - Do not try one more approach after the trigger fires, unless that approach is needed to reach a stable point. Extra attempts after the decision to escalate blur the record of what was tried and what state the work is in.

4. Record what was attempted, in order.
   - `attempted` is a list of short plain statements, one per attempt, in the order tried. Each entry names the action and the result, for example a retry that returned the same error or a check that ruled out one reading.
   - Include failed attempts. A packet that lists only the final failure hides whether the obvious fixes were tried, and the recipient will send the run back to try them.
   - State the retry limit from step 1 and whether it was reached. If the trigger fired before the limit, say why, for example because the next retry carried cost or harm risk.
   - At least one attempted entry is required. A packet with zero attempts is a request for first-line help, not an escalation, and the check script reports an empty `attempted` list as an error.

5. Assemble the packet fields.
   - Every packet carries these fields, with these meanings:
     - `packet_id`: a stable identifier for this packet, unique within the run record.
     - `run_id`: the identifier of the run that escalated, as it appears in the run record.
     - `task_summary`: one or two plain sentences naming the task and the goal.
     - `severity`: one tier from step 2.
     - `trigger_type`: one trigger from step 1.
     - `trigger_detail`: the concrete facts that fired the trigger, with numbers, names, or error text copied from the run record, not paraphrased from memory.
     - `attempted`: the ordered list from step 4.
     - `evidence`: the list from step 6.
     - `completed`: a list of the parts of the task that are done and verified.
     - `remaining`: a list of the parts still to do, including the blocked part.
     - `blocked_on`: the single point the run is stopped on, stated as one sentence.
     - `requested_action`: the exact decision or input needed, stated so a reply can give it in one line, for example pick value A or value B, approve a stated spend, or name the authoritative record.
     - `recipient`: the queue, role, system, or agent set in step 7.
     - `waiting_behavior`: one behavior from step 8.
     - `resume_condition`: the condition from step 10, stated as an observable event, for example a reply naming a value, not a vague hope that the case gets resolved.
   - Keep `blocked_on` and `requested_action` separate. The first states the problem point. The second states the decision that clears it. A packet that merges them forces the recipient to guess what reply would be enough.
   - Never put a secret value in a packet: no passwords, tokens, keys, or codes in any field, including evidence references. An evidence reference names a record. It never carries the credential itself.

6. Attach evidence for every factual claim in the packet.
   - Each evidence entry has two fields: `kind` and `ref`. Use only four kinds:
     - `link`: a URL the recipient can open to see the record.
     - `artifact`: an identifier for a produced file, export, or other output the recipient can retrieve.
     - `test`: an identifier for a named check or run with a recorded result.
     - `log`: an identifier for a run record or transcript that shows the work or the failure.
   - At least one evidence entry is required, and the check script reports an empty evidence list as an error. For `high` and `critical` packets, two entries are advised, and the script adds a warning when a high severity packet carries only one.
   - Evidence must support the trigger detail. A count disagreement needs the two counts or the records that produced them. A tool failure needs the log that shows the failure. General run history with no link to the trigger does not support the packet.
   - Copy figures in the trigger detail from the evidence, not from memory of the run. If a figure cannot be tied to an evidence entry, drop the figure or label it as unverified inside the trigger detail.

7. Route the packet to a named recipient.
   - Set the recipient in the design, per severity tier, before the run starts. Allowed recipient forms: a named review queue, a named role on call, a ticketing or tracking system the design names, or a supervisor agent with authority to decide the point. A packet addressed to nobody in particular is a note, not an escalation.
   - Write the delivery step in the design: where the packet is placed or sent, and how the run confirms delivery, for example a returned identifier or a record entry. An unconfirmed delivery is treated as undelivered, and the run follows the waiting behavior in step 8 while it retries delivery on a stated interval or reports the delivery failure as a second escalation.
   - One packet goes to one recipient. If two recipients could decide, the design names the primary and the packet names that one. Copying a second recipient for visibility is allowed when the design says so, and the copy must be marked as a copy so two decisions do not come back.
   - Record the routing in the run record: packet identifier, recipient, delivery confirmation, and time of delivery in the record format the run already uses.

8. Set the waiting behavior and follow it.
   - Use only four waiting behaviors, with fixed meanings:
     - `paused`: no further task work until the resume condition is met. Status checks and packet delivery retries are allowed.
     - `read_only`: the run may read, search, and verify records, and may prepare drafts or plans, and may not change stored state or spend.
     - `safe_subset`: the run may continue only the parts listed as unblocked in the design or in the packet, within the brief, and must stop at any point that touches the blocked question.
     - `stopped`: the run ends. Its state, packet, and records are handed over as they stand, and any later work starts as a new run that cites the packet.
   - Pick the behavior from the severity tier mapping in step 2 and write it in the packet. A behavior picked at escalation time, under pressure, tends to be looser than the design would allow.
   - Under `safe_subset`, list the exact parts that may continue in the packet or the run record. A general claim that other work is safe is not a list, and any part not listed waits.
   - While waiting, the run does not re-litigate the trigger, poll for a reply faster than the interval set in the design, or escalate the same point a second time unless the severity changes or the response window from step 2 passes with no reply. If the window passes, send one follow-up that cites the original packet identifier and states that the window passed.

9. Run the check script and fix every error before delivery.
   - Save the script from the Operating Rules section as packet_check.py. Save the packet as a JSON file. Run `python3 packet_check.py packet.json`.
   - Read the output in this order: errors first, then warnings, then the counts. Exit code 1 means at least one error exists. Exit code 0 means the packet passed the mechanical checks.
   - Fix errors at the source: add the missing field, add evidence, correct a bad severity, trigger, or waiting value, or fix the waiting behavior on a critical packet. Do not edit the script to silence a check that is pointing at a real gap.
   - Re-run the check after fixes and keep the final output with the packet. A packet is ready to route only when the final run shows 0 errors and verdict PASS.

10. Resume only on the stated condition, and close the case in the record.
    - Work may resume when the `resume_condition` in the packet is met and the reply or event that met it is recorded: who or what decided, what was decided, and the record that carries it. A reply that comments on the case without deciding the `requested_action` point does not meet the condition.
    - Before resuming, re-check the last verified good state from step 3. If state changed while waiting, reconcile the change first and note it in the run record. Resuming from a stale state after an outside change is a common way to redo or overwrite work.
    - Apply the decision narrowly. A decision on the blocked point clears that point only. It does not widen the brief, raise a cost limit, or grant access beyond what the decision states.
    - If the reply sets a new limit, a new instruction, or a different approach, record it in the design notes for the next run before resuming, so the same trigger carries the new rule from the start.
    - Close the escalation in the run record: packet identifier, decision, resume time or stop outcome, and any work discarded because the decision changed the plan. An open packet with no close entry leaves the next reader unable to tell whether the case was resolved or abandoned.

## Output Contract

Deliver the escalation design plus the checked packet and a short case summary, with these sections, in this order:

1. Design header: the task or run type the design covers, the trigger table from Workflow step 1 with its tests, and the severity mapping from step 2 with response windows, recipients, and waiting behaviors.
2. Packet document: the full JSON packet with all fields from step 5. Each field present, severity and trigger and waiting behavior drawn from the fixed value sets, at least one attempted entry, and at least one evidence entry.
3. Check output: the full output of packet_check.py on the delivered packet. On the worked incomplete fixture in the Operating Rules section the headline numbers are: attempted 1, completed 2, remaining 2, evidence_entries 0, errors 2, warnings 0, verdict FAIL. On the worked valid fixture the headline numbers are: attempted 2, completed 2, remaining 2, evidence_entries 2, errors 0, warnings 0, verdict PASS. A delivered packet should show errors 0 and verdict PASS, or carry a stated reason why it was routed while failing, which should be rare and limited to delivery channel failures.
4. Routing record: packet identifier, recipient, delivery confirmation, and delivery time as recorded in step 7.
5. Waiting plan: the waiting behavior in force, the parts that may continue if the behavior is safe_subset or read_only, and the follow-up time if the response window passes.
6. Resume record: the resume condition, the decision that met it when it is met, the state recheck result from step 10, and the close entry. Mark this section as pending while the case is open.
7. Design changes: any trigger test, severity mapping, or rule changed because of this case, recorded for the next run.

## Operating Rules

1. A trigger fires on its written test, not on a general sense that the run is in trouble. If no written test covers the case, escalate under the closest trigger type, state in the trigger detail that no test covered the case, and add a test to the design in step 10.
2. Every packet needs evidence and at least one attempted entry. An empty evidence list and an empty requested_action are errors, not style choices. The check script enforces both rules mechanically, and a delivered packet shows 0 such errors.
3. No secret values in a packet. Passwords, tokens, keys, and codes never appear in any field, including evidence references and trigger detail text. Naming a record or a tool is allowed. Giving its credential is not.
4. Severity follows consequence, and critical means stopped or paused. A critical packet with waiting behavior read_only or safe_subset fails the check. Lowering severity to dodge that rule, when the consequence has not changed, is a false claim in the packet.
5. One packet, one recipient, one requested decision. A packet that asks for two decisions from two recipients splits into two packets, each citing the other, so each case can close on its own reply.
6. Absolute rules in this skill are complete required fields, valid severity and trigger and waiting values, evidence per packet, attempted work before escalation, and the critical waiting rule, because breaking any of them makes a packet hard to act on without a round trip. Trigger tests, response windows, retry limits, and recipient names are guidance set by this skill: pick values that fit the task, record them in the design, and keep them fixed for the run.
7. No invented queues, services, or calls. A packet is a JSON document and a check result, delivered by a channel the design names. This skill invents no queue to send it to, no service to route it, and no call to validate it beyond the inlined script. Where packets are kept is a local choice, recorded in the run record.
8. Use the packet check script below for the mechanical checks. Save it as packet_check.py, run it as its header describes, and put its output in the delivered summary. Report numbers from its output, not from memory of the packet.

### Packet check (packet_check.py)

Run: `python3 packet_check.py --make-fixture` reproduces the incomplete worked example. Run: `python3 packet_check.py --make-valid` reproduces the valid worked example. Run it on a real packet with `python3 packet_check.py packet.json`.

Expected output for the fixture run:

```text
packet_id: esc-2026-10-16-004
run_id: run-8841
severity: high
trigger_type: blocked
waiting_behavior: paused
attempted: 1
completed: 2
remaining: 2
evidence_entries: 0
errors: 2
  error: requested_action: missing or empty
  error: evidence: missing, not a list, or empty
warnings: 0
verdict: FAIL
```

Expected output for the valid run:

```text
packet_id: esc-2026-10-16-005
run_id: run-8842
severity: medium
trigger_type: uncertain
waiting_behavior: read_only
attempted: 2
completed: 2
remaining: 2
evidence_entries: 2
errors: 0
warnings: 0
verdict: PASS
```

The incomplete fixture is built to trip two rules: its `requested_action` is an empty string, and its `evidence` list is empty. All other fixture fields are present and valid, with attempted 1, completed 2, and remaining 2. The valid fixture carries the same task with a filled `requested_action` and 2 evidence entries, and passes with 0 errors.

packet_check.py (uses only the Python standard library). Save this code as packet_check.py:

```python
#!/usr/bin/env python3
"""Check an agent escalation packet for completeness.

Usage:
  python3 packet_check.py packet.json
  python3 packet_check.py --make-fixture
  python3 packet_check.py --make-valid

The packet is a JSON object with these top level fields:
  packet_id (string), run_id (string), task_summary (string),
  severity (string), trigger_type (string), trigger_detail (string),
  attempted (list of strings), evidence (list of objects),
  completed (list of strings), remaining (list of strings),
  blocked_on (string), requested_action (string),
  recipient (string), waiting_behavior (string),
  resume_condition (string).

Severity must be one of: low, medium, high, critical.
Trigger type must be one of: blocked, uncertain, harm_risk,
  cost_risk, permission_denied, conflicting_instructions,
  missing_input, tool_failure.
Waiting behavior must be one of: paused, read_only,
  safe_subset, stopped.
Each evidence entry needs kind and ref.
Kind must be one of: link, artifact, test, log.

Exit code is 0 when there are no errors, 1 when there is
at least one error. Warnings do not change the exit code.
"""
import argparse
import json
import sys

SEVERITIES = {"low", "medium", "high", "critical"}
TRIGGERS = {"blocked", "uncertain", "harm_risk", "cost_risk",
            "permission_denied", "conflicting_instructions",
            "missing_input", "tool_failure"}
WAITING = {"paused", "read_only", "safe_subset", "stopped"}
KINDS = {"link", "artifact", "test", "log"}

REQUIRED_STRINGS = ["packet_id", "run_id", "task_summary",
                    "trigger_detail", "blocked_on",
                    "requested_action", "recipient",
                    "resume_condition"]

FIXTURE = {
    "packet_id": "esc-2026-10-16-004",
    "run_id": "run-8841",
    "task_summary": "Migrate 120 records and verify totals match source.",
    "severity": "high",
    "trigger_type": "blocked",
    "trigger_detail": "Source totals disagree after step 3, difference is 14 records.",
    "attempted": ["Re-ran the count query with a fixed filter."],
    "evidence": [],
    "completed": ["Read source file.", "Loaded 106 records."],
    "remaining": ["Load remaining records.", "Verify totals."],
    "blocked_on": "Which total is authoritative, source or target.",
    "requested_action": "",
    "recipient": "review-queue",
    "waiting_behavior": "paused",
    "resume_condition": "A reply naming the authoritative total."
}

VALID = {
    "packet_id": "esc-2026-10-16-005",
    "run_id": "run-8842",
    "task_summary": "Migrate 120 records and verify totals match source.",
    "severity": "medium",
    "trigger_type": "uncertain",
    "trigger_detail": "Two records share an identifier with different values.",
    "attempted": ["Checked both records against the source export.",
                "Searched the run log for a prior decision on duplicates."],
    "evidence": [{"kind": "log", "ref": "run-8842-step-2"},
                 {"kind": "artifact", "ref": "export-2026-10-16.csv"}],
    "completed": ["Read source file.", "Loaded 118 records."],
    "remaining": ["Resolve the duplicate pair.", "Verify totals."],
    "blocked_on": "Which value wins for the duplicate identifier.",
    "requested_action": "Pick value A or value B for the duplicate identifier.",
    "recipient": "review-queue",
    "waiting_behavior": "read_only",
    "resume_condition": "A reply naming the winning value."
}


def check_str_list(value, label, errors, allow_empty=True):
    if not isinstance(value, list):
        errors.append(f"{label}: missing or not a list")
        return 0
    if not allow_empty and len(value) == 0:
        errors.append(f"{label}: empty list")
        return 0
    good = 0
    for item in value:
        if not isinstance(item, str) or not item.strip():
            errors.append(f"{label}: entry is empty or not a string")
        else:
            good += 1
    return good


def check_packet(packet):
    errors = []
    warnings = []
    if not isinstance(packet, dict):
        return ["packet is not a JSON object"], warnings, {}
    for field in REQUIRED_STRINGS:
        val = packet.get(field)
        if not isinstance(val, str) or not val.strip():
            errors.append(f"{field}: missing or empty")
    severity = packet.get("severity")
    if severity not in SEVERITIES:
        errors.append(f"severity: bad value {severity!r}")
        severity_out = "-"
    else:
        severity_out = severity
    trigger = packet.get("trigger_type")
    if trigger not in TRIGGERS:
        errors.append(f"trigger_type: bad value {trigger!r}")
        trigger_out = "-"
    else:
        trigger_out = trigger
    waiting = packet.get("waiting_behavior")
    if waiting not in WAITING:
        errors.append(f"waiting_behavior: bad value {waiting!r}")
        waiting_out = "-"
    else:
        waiting_out = waiting
    attempted_n = check_str_list(packet.get("attempted"), "attempted", errors, allow_empty=False)
    completed_n = check_str_list(packet.get("completed"), "completed", errors, allow_empty=True)
    remaining_n = check_str_list(packet.get("remaining"), "remaining", errors, allow_empty=True)
    if isinstance(packet.get("remaining"), list) and len(packet.get("remaining")) == 0:
        warnings.append("remaining: empty list, no remaining work stated")
    evidence = packet.get("evidence")
    evidence_total = 0
    if not isinstance(evidence, list) or len(evidence) == 0:
        errors.append("evidence: missing, not a list, or empty")
        evidence = []
    else:
        for entry in evidence:
            if not isinstance(entry, dict):
                errors.append("evidence: entry is not an object")
                continue
            kind = entry.get("kind")
            ref = entry.get("ref")
            if kind not in KINDS:
                errors.append(f"evidence: bad kind {kind!r}")
                continue
            if not isinstance(ref, str) or not ref.strip():
                errors.append("evidence: ref is empty")
                continue
            evidence_total += 1
    if severity in ("high", "critical") and evidence_total < 2 and isinstance(evidence, list) and len(evidence) > 0:
        warnings.append(f"severity {severity}: only {evidence_total} evidence entry, 2 advised")
    if severity == "critical" and waiting not in ("paused", "stopped"):
        errors.append("severity critical: waiting_behavior must be paused or stopped")
    stats = {
        "packet_id": packet.get("packet_id") if isinstance(packet.get("packet_id"), str) and packet.get("packet_id").strip() else "-",
        "run_id": packet.get("run_id") if isinstance(packet.get("run_id"), str) and packet.get("run_id").strip() else "-",
        "severity": severity_out,
        "trigger_type": trigger_out,
        "waiting_behavior": waiting_out,
        "attempted": attempted_n,
        "completed": completed_n,
        "remaining": remaining_n,
        "evidence_total": evidence_total,
    }
    return errors, warnings, stats


def print_report(errors, warnings, stats):
    print(f"packet_id: {stats.get('packet_id', '-')}")
    print(f"run_id: {stats.get('run_id', '-')}")
    print(f"severity: {stats.get('severity', '-')}")
    print(f"trigger_type: {stats.get('trigger_type', '-')}")
    print(f"waiting_behavior: {stats.get('waiting_behavior', '-')}")
    print(f"attempted: {stats.get('attempted', 0)}")
    print(f"completed: {stats.get('completed', 0)}")
    print(f"remaining: {stats.get('remaining', 0)}")
    print(f"evidence_entries: {stats.get('evidence_total', 0)}")
    print(f"errors: {len(errors)}")
    for e in errors:
        print(f"  error: {e}")
    print(f"warnings: {len(warnings)}")
    for w in warnings:
        print(f"  warning: {w}")
    verdict = "FAIL" if errors else "PASS"
    print(f"verdict: {verdict}")


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("path", nargs="?", default=None)
    parser.add_argument("--make-fixture", action="store_true")
    parser.add_argument("--make-valid", action="store_true")
    args = parser.parse_args()
    if args.make_fixture:
        packet = FIXTURE
    elif args.make_valid:
        packet = VALID
    elif args.path:
        try:
            with open(args.path, "r", encoding="utf-8") as fh:
                packet = json.load(fh)
        except (OSError, json.JSONDecodeError) as exc:
            print(f"error: cannot read packet: {exc}")
            return 2
    else:
        print("error: give a packet path, --make-fixture, or --make-valid")
        return 2
    errors, warnings, stats = check_packet(packet)
    print_report(errors, warnings, stats)
    return 1 if errors else 0


if __name__ == "__main__":
    raise SystemExit(main())
```
