From cfdd5ba7646a1971f13397ed00f1ea5f777940fc Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Mon, 7 Sep 2026 11:21:55 -0400 Subject: [PATCH 01/49] Approve from the CLI in the leads demo, and print commands that run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `charter pending` and `charter describe` suggested an approve, reject or answer command without `--instance`. Every applied agent has an instance, so copying the suggestion exits 1 with "has 1 instance — say which". Both now name the instance they resolved. A test runs every command a gate prints and asserts it exits 0, so a newly required flag fails here rather than in someone's terminal. `demo/leads/approve.py` is deleted. It opened with "Charter has no approve command", which stopped being true, and a Python script for approvals reads as missing product in a demo. The README now uses `charter pending` and `charter approve`, and points at `charter ui` for the same thing in a browser. --- charter/cli.py | 18 ++++--- demo/leads/README.md | 22 ++++++--- demo/leads/approve.py | 108 ------------------------------------------ demo/leads/inbox.py | 4 +- tests/test_cli.py | 50 +++++++++++++++++++ 5 files changed, 79 insertions(+), 123 deletions(-) delete mode 100644 demo/leads/approve.py diff --git a/charter/cli.py b/charter/cli.py index c1f8ca4..da81d4e 100644 --- a/charter/cli.py +++ b/charter/cli.py @@ -1118,13 +1118,16 @@ async def go(): if wf.pending_approval: g = wf.pending_approval ui.gate(agent, "approval", g.approval_id, g.justification, [ - f"charter approve {g.approval_id} --agent {agent} --reason '...'", - f"charter reject {g.approval_id} --agent {agent} --reason '...'", + f"charter approve {g.approval_id} --agent {agent} " + f"--instance {short(wf.id)} --reason '...'", + f"charter reject {g.approval_id} --agent {agent} " + f"--instance {short(wf.id)} --reason '...'", ], timeout=_when(g.timeout_at)) elif wf.pending_input: g = wf.pending_input ui.gate(agent, "an answer", g.input_id, g.prompt, [ - f"charter answer {g.input_id} '...' --agent {agent}"], + f"charter answer {g.input_id} '...' --agent {agent} " + f"--instance {short(wf.id)}"], timeout=_when(g.timeout_at)) asyncio.run(go()) @@ -1446,13 +1449,16 @@ async def go(): if wf.pending_approval: g = wf.pending_approval ui.gate(agent, "approval", g.approval_id, g.justification, [ - f"charter approve {g.approval_id} --agent {agent} --reason '...'", - f"charter reject {g.approval_id} --agent {agent} --reason '...'", + f"charter approve {g.approval_id} --agent {agent} " + f"--instance {short(wf.id)} --reason '...'", + f"charter reject {g.approval_id} --agent {agent} " + f"--instance {short(wf.id)} --reason '...'", ], timeout=_when(g.timeout_at)) elif wf.pending_input: g = wf.pending_input ui.gate(agent, "an answer", g.input_id, g.prompt, [ - f"charter answer {g.input_id} '...' --agent {agent}", + f"charter answer {g.input_id} '...' --agent {agent} " + f"--instance {short(wf.id)}", ], timeout=_when(g.timeout_at)) else: ui.dim(f"{agent}: nothing waiting ({wf.lifecycle_state.value})") diff --git a/demo/leads/README.md b/demo/leads/README.md index c0e2a57..8552f41 100644 --- a/demo/leads/README.md +++ b/demo/leads/README.md @@ -26,7 +26,6 @@ It is one agent and a config file. There is no pipeline code. roll it back network.py the fake network, as an MCP server - approve.py you, approving what the agent wants to send inbox.py you, as the people being contacted Every field the schema accepts appears in those four YAML files. Anything the demo @@ -84,14 +83,23 @@ as a subprocess relative to wherever the worker runs: cd demo/leads && charter worker . -Both gated tools stop for a human, and nothing here routes a notification, so run -the approval console in a second terminal or the agent sits at its first gate -until it times out: +Both gated tools stop for a human, and nothing here routes a notification, so +watch for gates from a second terminal or the agent sits at its first gate until +it times out: - python demo/leads/approve.py # read each one, decide - python demo/leads/approve.py --auto # approve everything, hands-off + charter pending leads-finder --instance -And the inbox in a third, which is you as the people being contacted: +That prints the call it wants to make and the two commands that answer it, which +take a reason because an approval nobody can explain later is not much of one: + + charter approve --agent leads-finder --instance --reason '...' + charter reject --agent leads-finder --instance --reason '...' + +The console does the same in a browser, across every agent at once: + + charter ui + +And the inbox in a third terminal, which is you as the people being contacted: python demo/leads/inbox.py diff --git a/demo/leads/approve.py b/demo/leads/approve.py deleted file mode 100644 index 2a2baf0..0000000 --- a/demo/leads/approve.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Sign off on whatever the outreach agents are waiting for. - -Charter has no `approve` command — approving is a control-plane action, and the -CLI deliberately doesn't reach past it. So this is a small operator's console -built on the same public API a real one would use. - - python demo/leads/approve.py # watch, and prompt for each gate - python demo/leads/approve.py --auto # approve everything, for a hands-off run - -Each gate shows the justification the agent gave, which is the tool call it wants -to make and why. Approving lets that exact call through; rejecting ends the task, -because outreach declares `on_reject: fail` — a run that reported success without -sending the message would be lying. -""" - -import argparse -import asyncio -import os -import sys - -from boundflow import ControlPlaneClient - -POLL_SECONDS = 3 - - -async def pending(cp, tenant_id: str): - """Every workflow currently stopped for a human, with its open gate. - - `list_workflows` returns a light view without the gate itself, so anything - that looks stopped is fetched in full to find out what it is asking for. - """ - out = [] - for w in await cp.list_workflows(): - if w.tenant_id != tenant_id: - continue - state = getattr(w.lifecycle_state, "name", str(w.lifecycle_state)) - if "AWAITING" not in state.upper(): - continue - full = await cp.get_workflow(w.id) - if full.pending_approval is not None: - out.append((full, full.pending_approval)) - return out - - -async def main() -> None: - ap = argparse.ArgumentParser() - ap.add_argument("--auto", action="store_true", - help="approve everything without asking") - ap.add_argument("--tenant", default="default") - args = ap.parse_args() - - cp = ControlPlaneClient(os.environ["BOUNDFLOW_SERVER_ADDRESS"], - os.environ["BOUNDFLOW_API_KEY"]) - async with cp: - try: - tenants = await cp.list_tenants() - except Exception as e: # noqa: BLE001 - # Fail here rather than retry: if the control plane is unreachable at - # startup that is a wrong address or a server that isn't up, and neither - # gets better by waiting. - sys.exit(f"can't reach the control plane at " - f"{os.environ['BOUNDFLOW_SERVER_ADDRESS']}: " - f"{e.__class__.__name__}") - tenant = next((t for t in tenants if t.name == args.tenant), None) - if tenant is None: - sys.exit(f"no tenant named {args.tenant!r}") - - print(f"watching {args.tenant} for approvals " - f"({'auto-approving' if args.auto else 'interactive'}) — ctrl-c to stop\n") - seen: set[str] = set() - while True: - try: - waiting = await pending(cp, tenant.id) - except Exception as e: # noqa: BLE001 - # Keep watching. A console that exits on a blip leaves gates sitting - # unapproved with nobody looking and nothing saying it stopped — - # which reads exactly like an agent that has gone quiet. - print(f" control plane unreachable ({e.__class__.__name__}), " - f"retrying") - await asyncio.sleep(POLL_SECONDS) - continue - - for workflow, gate in waiting: - if gate.approval_id in seen: - continue - seen.add(gate.approval_id) - print(f"── {workflow.workflow_type} {workflow.id[:8]}") - print(f" {gate.justification}\n") - if args.auto: - verdict = "y" - else: - verdict = input(" approve? [y/N] ").strip().lower() - if verdict == "y": - await cp.approve_workflow(workflow.id, gate.approval_id, - actor="demo", reason="looks good") - print(" approved\n") - else: - await cp.reject_workflow(workflow.id, gate.approval_id, - actor="demo", reason="not sending that") - print(" rejected\n") - await asyncio.sleep(POLL_SECONDS) - - -if __name__ == "__main__": - try: - asyncio.run(main()) - except KeyboardInterrupt: - print("\nstopped watching") diff --git a/demo/leads/inbox.py b/demo/leads/inbox.py index 0624091..4c881b5 100644 --- a/demo/leads/inbox.py +++ b/demo/leads/inbox.py @@ -8,8 +8,8 @@ python demo/leads/inbox.py Shows what is waiting on you and lets you answer it. Runs alongside the worker and -`approve.py`: that one is you approving your own agent's outbound messages, this -one is the other side answering. Ctrl-C to leave; nothing is lost, because it all +`charter pending`: that one is you approving your own agent's outbound messages, +this one is the other side answering. Ctrl-C to leave; nothing is lost, because it all lives in network.db. """ diff --git a/tests/test_cli.py b/tests/test_cli.py index c439bba..225103a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -80,6 +80,8 @@ def __init__(self, workflows=None, request=None, runs=None, metrics=None): total_llm_calls=7, total_latency_seconds=12.0, total_approval_rejections=0, tool_failure_counts={}) self.approved = [] + self.rejected = [] + self.answered = [] self.resumed: list = [] self.suspended: list = [] @@ -116,6 +118,12 @@ async def get_workflow_lifecycle_policy(self, workflow_id): async def approve_workflow(self, workflow_id, approval_id, actor="", reason=""): self.approved.append((approval_id, actor, reason)) + async def reject_workflow(self, workflow_id, approval_id, actor="", reason=""): + self.rejected.append((approval_id, actor, reason)) + + async def submit_input(self, workflow_id, input_id, values, actor=""): + self.answered.append((input_id, values, actor)) + async def resume_workflow(self, workflow_id, suspension_id): self.resumed.append((workflow_id, suspension_id)) @@ -701,3 +709,45 @@ def test_the_console_names_its_extra_when_it_is_missing(monkeypatch): assert res.exit_code == 1 assert "boundflow-charter[ui]" in res.output assert "Traceback" not in res.output + + +def _printed_commands(output: str) -> list[str]: + """The `charter ...` lines a gate renders as suggestions.""" + return [line.strip() for line in output.splitlines() + if line.strip().startswith("charter ")] + + +class TestPrintedCommandsRun: + """A command the CLI prints has to be one the CLI accepts. + + `charter pending` suggested `charter approve --agent --reason ...`, + which exits 1 with "has 1 instance — say which" the moment an agent has an + instance, and every applied agent has one. Copying the suggestion was the + documented path in the leads demo. + """ + + def _gate(self, cp): + cp.workflows = [workflow( + "refund-demo", lifecycle_state=LifecycleState.AWAITING_APPROVAL, + pending=PendingApproval(approval_id="apr_1", justification="run it", + metadata={}, opened_at=NOW, timeout_at=None))] + + def test_pending_suggests_commands_that_parse(self, cp): + self._gate(cp) + printed = _printed_commands(invoke("pending", "refund-demo", + "--instance", "wf_refun").output) + assert printed, "the gate rendered no commands" + for cmd in printed: + args = [a.strip("'") for a in cmd.split()[1:]] + res = invoke(*args) + assert res.exit_code == 0, f"{cmd!r} exits {res.exit_code}: {res.output}" + + def test_describe_suggests_commands_that_parse(self, cp): + self._gate(cp) + printed = _printed_commands(invoke("describe", "refund-demo", + "--instance", "wf_refun").output) + assert printed, "the gate rendered no commands" + for cmd in printed: + args = [a.strip("'") for a in cmd.split()[1:]] + res = invoke(*args) + assert res.exit_code == 0, f"{cmd!r} exits {res.exit_code}: {res.output}" From 728ea944f8f990b0f32ccc337e47964be3ccc19b Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Mon, 7 Sep 2026 11:44:00 -0400 Subject: [PATCH 02/49] Replace the leads demo with examples that run `examples/` named real Zendesk and Stripe servers and was documented as not running. `demo/leads` ran, but needed four terminals, a simulated professional network and you role-playing the people being contacted, and an agent writing cold outreach at scale is the wrong thing for a governance product to demonstrate. Both are now one toy support desk. `examples/desk.py` is a small MCP server with four tickets and the charges behind them; `refund-triage` reads a ticket and gates the refund, `ticket-summarizer` reads and reports. One domain across both, so what differs is the capability rather than the scenario. They need a model key and nothing else. `refund-triage` gains the skill the deleted demo was carrying, so skills are still shown somewhere. `worker.yaml` keeps its notification block, which resolves lazily, and comments out the OTel sink, which does not and stopped the worker booting. Tests that loaded the demo or hardcoded the old server names move to the desk. `test_an_ordinary_tool_failure_does_not_end_the_task` was passing on a tool name no config declared, which made it vacuous; it now names a declared tool that is not fail-fast. --- README.md | 6 +- demo/leads/README.md | 130 ------------ demo/leads/inbox.py | 122 ------------ demo/leads/leads-finder/lifecycle.yaml | 47 ----- demo/leads/leads-finder/runtime.yaml | 38 ---- demo/leads/leads-finder/v1.yaml | 139 ------------- .../leads-finder/v1/skills/boundflow/SKILL.md | 61 ------ demo/leads/network.py | 188 ------------------ demo/leads/worker.yaml | 53 ----- examples/README.md | 46 +++++ examples/desk.py | 95 +++++++++ examples/refund-triage/lifecycle.yaml | 2 +- examples/refund-triage/runtime.yaml | 4 +- examples/refund-triage/v1.yaml | 18 +- .../v1/skills/refund-policy/SKILL.md | 33 +++ examples/ticket-summarizer/runtime.yaml | 2 +- examples/ticket-summarizer/v1.yaml | 7 +- examples/ticket-summarizer/v2.yaml | 7 +- examples/worker.yaml | 7 +- tests/test_agent_config.py | 16 +- tests/test_artifact.py | 4 +- tests/test_compile.py | 6 +- tests/test_docs.py | 2 +- tests/test_harness_bounds.py | 12 +- tests/test_loader.py | 4 +- tests/test_loop.py | 20 +- 26 files changed, 227 insertions(+), 842 deletions(-) delete mode 100644 demo/leads/README.md delete mode 100644 demo/leads/inbox.py delete mode 100644 demo/leads/leads-finder/lifecycle.yaml delete mode 100644 demo/leads/leads-finder/runtime.yaml delete mode 100644 demo/leads/leads-finder/v1.yaml delete mode 100644 demo/leads/leads-finder/v1/skills/boundflow/SKILL.md delete mode 100644 demo/leads/network.py delete mode 100644 demo/leads/worker.yaml create mode 100644 examples/README.md create mode 100644 examples/desk.py create mode 100644 examples/refund-triage/v1/skills/refund-policy/SKILL.md diff --git a/README.md b/README.md index 7cbb17c..82295d7 100644 --- a/README.md +++ b/README.md @@ -288,10 +288,8 @@ through their workers and the control plane whether or not the CLI is installed. - [DESIGN.md](DESIGN.md): every field of every file, and the decisions behind them - [deploy/](deploy/): running workers as containers, and a control plane locally -- [examples/](examples/): fuller configurations, for reading. They name real - Zendesk and Stripe servers, so they do not run as-is -- [demo/leads/](demo/leads/): an agent that runs end to end against a local MCP - server, where you play the people it contacts +- [examples/](examples/): two agents over a toy support desk, one of them gating + a refund. They run with nothing but a model key ## Development diff --git a/demo/leads/README.md b/demo/leads/README.md deleted file mode 100644 index 8552f41..0000000 --- a/demo/leads/README.md +++ /dev/null @@ -1,130 +0,0 @@ -# The leads pipeline - -Find people worth talking to, connect with each of them, get a human to sign off -the first message, and keep the conversation — with the waiting, the approvals and -the durability that implies. No LinkedIn: `network.py` is a fake professional -network with SQLite behind it, so the whole thing runs locally with nothing at -stake and nobody real on the other end. - -It is one agent and a config file. There is no pipeline code. - -## The files - - worker.yaml the deployment — which control plane, whose - credentials, which agents this process serves - - leads-finder/ - v1.yaml behaviour at v1: objective, tools, answer - shape. immutable — you write v2, you don't - edit v1 - v1/skills/boundflow/SKILL.md what it knows. versioned with v1, because a - rollback has to restore what the agent knew - runtime.yaml policy: spend ceilings, what it may reach, - how long a human has. mutable, re-applied - lifecycle.yaml what the control plane does to the agent - between tasks — pause it, cool it down, - roll it back - - network.py the fake network, as an MCP server - inbox.py you, as the people being contacted - -Every field the schema accepts appears in those four YAML files. Anything the demo -doesn't use is commented rather than omitted, so the file is the reference. - -**Which half goes where.** `charter push` seals `v1.yaml` + `v1/skills/` into an -artifact. `charter apply` sends `runtime.yaml` and `lifecycle.yaml` to the control -plane. Behaviour is packaged because it has to reach workers that may not exist -yet. Policy is applied because it has to stay changeable — a budget you cannot -lower without cutting a release is not a budget. - -## What it exercises - -One agent runs the whole campaign: - - search_people - send_connection_request ← you approve each one, with its note - wait ... connection_status - send_message ← you approve each one, with its text - wait ... conversation - send_message (a reply) ← you approve that too - ... and round again while anyone might still answer - -Everything that reaches a person stops for you. Everything else runs on its own. - -The waiting is real: `inbox.py` is where you play the people being contacted, so -someone accepts when you accept and replies when you write a reply. Nothing moves -on a timer, which is the only way to see an agent genuinely wait on a person. - -## Running it - -Run these with Charter's environment active — the agent spawns its tool server as -`python network.py`, and it needs the interpreter Charter is installed in. - -Six environment variables — the control plane's two addresses, its key, the tenant, -Postgres for the harness's own state, and a model key: - - export BOUNDFLOW_SERVER_ADDRESS=http://localhost:50051 - export BOUNDFLOW_WORKER_ADDRESS=http://localhost:50052 - export BOUNDFLOW_API_KEY=... - export CHARTER_TENANT=default - export CHARTER_STORE_URL=postgres://... - export ANTHROPIC_API_KEY=... - -If you brought the control plane up with `deploy/local.compose.yml`, the store is -`postgres://charter:charter@localhost:5434/charter`. - -The tenant has to exist before an agent can live in it. Create it once, then the -instances, then start the worker from this directory — the MCP server is spawned -as a subprocess relative to wherever the worker runs: - - charter tenant create default # once per control plane - charter agent create leads-finder --path demo/leads # prints an instance id - charter apply demo/leads/worker.yaml --all - - cd demo/leads && charter worker . - -Both gated tools stop for a human, and nothing here routes a notification, so -watch for gates from a second terminal or the agent sits at its first gate until -it times out: - - charter pending leads-finder --instance - -That prints the call it wants to make and the two commands that answer it, which -take a reason because an approval nobody can explain later is not much of one: - - charter approve --agent leads-finder --instance --reason '...' - charter reject --agent leads-finder --instance --reason '...' - -The console does the same in a browser, across every agent at once: - - charter ui - -And the inbox in a third terminal, which is you as the people being contacted: - - python demo/leads/inbox.py - -Then, from the repo root, naming the instance `create` printed: - - charter run leads-finder --path demo/leads --instance --topic agent-governance - -Every command that acts on an agent names an instance, including when there is -only one — `charter agents` lists them if you lose the id. - -The conversation is in `network.db` — delete it to start over. - -## Timing - -Nothing here is on a clock. The agent waits, and someone accepts or replies when -you do it in `inbox.py`. If you want to see it give up on someone, just never -accept them. - -How long it sleeps between checks is its own choice, bounded by -`max_wait_seconds` in `runtime.yaml` — 5 minutes here, because a demo you cannot -watch is not a demo. Left at a production ceiling the agent will happily pick two -hours, which is correct behaviour and useless to sit through. That limit is policy -rather than versioned behaviour, so `charter apply` changes it on the next round -without restarting the worker. - -The agent doesn't know any of that. It calls `connection_status` and -`conversation` and gets whatever is true, which is exactly what it would do -against a real network. diff --git a/demo/leads/inbox.py b/demo/leads/inbox.py deleted file mode 100644 index 4c881b5..0000000 --- a/demo/leads/inbox.py +++ /dev/null @@ -1,122 +0,0 @@ -"""You, as the people the agent is reaching out to. - -The agent thinks it is on a professional network. It sends a connection request -and waits; it sends a message and waits. Nothing here happens on a timer — you -decide when someone accepts and what they say back, which is the only way the -waiting is real. - - python demo/leads/inbox.py - -Shows what is waiting on you and lets you answer it. Runs alongside the worker and -`charter pending`: that one is you approving your own agent's outbound messages, -this one is the other side answering. Ctrl-C to leave; nothing is lost, because it all -lives in network.db. -""" - -import sqlite3 -import sys -import time -from pathlib import Path - -DB = Path(__file__).parent / "network.db" - -PEOPLE = { - "ade": "Ade Okonkwo", - "mira": "Mira Castellanos", - "dana": "Dana Whitfield", - "tomasz": "Tomasz Nowak", - "priya": "Priya Raman", -} - - -def db() -> sqlite3.Connection: - if not DB.exists(): - sys.exit(f"no {DB.name} yet — run the agent first, it creates it") - conn = sqlite3.connect(DB) - conn.row_factory = sqlite3.Row - return conn - - -def waiting(conn) -> list[tuple[str, str, str]]: - """What needs you: (person_id, kind, context). - - Two kinds. An unaccepted request is someone waiting to be let in; a trailing - outbound message is someone who has been written to and hasn't answered. - """ - out = [] - for row in conn.execute( - "SELECT person_id, note, accepted_at FROM connections ORDER BY requested_at"): - if row["accepted_at"] is None: - out.append((row["person_id"], "request", row["note"])) - continue - last = conn.execute( - "SELECT direction, text FROM messages WHERE person_id = ? " - "ORDER BY at DESC, id DESC LIMIT 1", (row["person_id"],)).fetchone() - if last and last["direction"] == "sent": - out.append((row["person_id"], "message", last["text"])) - return out - - -def accept(conn, person_id: str) -> None: - with conn: - conn.execute("UPDATE connections SET accepted_at = ? WHERE person_id = ?", - (time.time(), person_id)) - - -def reply(conn, person_id: str, text: str) -> None: - with conn: - conn.execute( - "INSERT INTO messages (person_id, direction, text, at) VALUES (?,?,?,?)", - (person_id, "received", text, time.time())) - - -def wrap(text: str, indent: str = " ") -> str: - import textwrap - return textwrap.fill(text, 76, initial_indent=indent, subsequent_indent=indent) - - -def main() -> None: - conn = db() - print("\nyou are the other side. ctrl-c to leave.\n") - while True: - pending = waiting(conn) - if not pending: - print("nothing waiting on you — leave this open, the agent is working") - time.sleep(3) - # Reconnect so a write from the worker's process is visible. - conn = db() - continue - - for person_id, kind, context in pending: - name = PEOPLE.get(person_id, person_id) - print(f"\n── {name}") - if kind == "request": - print(" wants to connect:") - print(wrap(context)) - answer = input("\n accept? [y/N/skip] ").strip().lower() - if answer == "y": - accept(conn, person_id) - print(" connected") - elif answer in ("s", "skip"): - # Left pending, so it comes back next time round. This is how - # you play someone who simply hasn't got to it yet. - print(" left waiting") - else: - print(" ignored for now") - else: - print(" said to you:") - print(wrap(context)) - answer = input("\n your reply (blank to say nothing yet): ").strip() - if answer: - reply(conn, person_id, answer) - print(" sent") - else: - print(" left unanswered") - conn = db() - - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - print("\nleft the inbox") diff --git a/demo/leads/leads-finder/lifecycle.yaml b/demo/leads/leads-finder/lifecycle.yaml deleted file mode 100644 index 8ada705..0000000 --- a/demo/leads/leads-finder/lifecycle.yaml +++ /dev/null @@ -1,47 +0,0 @@ -apiVersion: charter/v1 -kind: LifecyclePolicy - -agent: leads-finder - -rules: - - when: - metric: num_failures - threshold: 3 - then: - pause: - window: 10 - - - when: - metric: approval_rejections - threshold: 5 - then: - cooldown: - window: 20 - seconds: 3600 - - # - when: - # metric: cost - # threshold: 25.0 - # then: - # set_version: - # target: 1 - # - when: - # metric: num_llm_calls - # threshold: 500 - # then: - # pause: - # window: 20 - # - when: - # metric: latency - # threshold: 600 - # then: - # cooldown: - # window: 10 - # seconds: 900 - # - when: - # metric: tool_failures - # tool: net__send_message - # threshold: 5 - # then: - # pause: - # window: 20 diff --git a/demo/leads/leads-finder/runtime.yaml b/demo/leads/leads-finder/runtime.yaml deleted file mode 100644 index db2e066..0000000 --- a/demo/leads/leads-finder/runtime.yaml +++ /dev/null @@ -1,38 +0,0 @@ -apiVersion: charter/v1 -kind: RuntimePolicy - -agent: leads-finder - -per_run: - max_cost_usd: 3.00 - max_llm_calls: 80 - max_seconds: 120 - max_tool_failures: 3 - # max_queue_depth: 100 - # max_total_subagents: 5 - # max_parallel_subagents: 2 - # tool_call_limits: - # - tool: net__send_message - # max_calls: 10 - # capability_call_limits: - # - capability: read - # max_calls: 40 - -limits: - max_tokens_per_call: 1024 - max_call_seconds: 60 - max_tool_seconds: 30 - -authority: - allowed_capabilities: [read] - approval_timeout_seconds: 86400 - question_timeout_seconds: 86400 - # allowed_spawns: [outreach] - max_wait_seconds: 300 - # file_rules: - # - operations: [read] - # paths: ["/skills/**"] - # mode: allow - # - operations: [write] - # paths: ["/**"] - # mode: deny diff --git a/demo/leads/leads-finder/v1.yaml b/demo/leads/leads-finder/v1.yaml deleted file mode 100644 index ade6720..0000000 --- a/demo/leads/leads-finder/v1.yaml +++ /dev/null @@ -1,139 +0,0 @@ -apiVersion: charter/v1 -kind: AgentConfig - -name: leads-finder -version: 1 -description: Finds people worth talking to and runs the conversation with each of them, with you signing off everything that reaches a person. - -model: claude-haiku-4-5 - -inputs: - topic: - type: string - required: true - description: What kind of person to look for, e.g. agent-governance. - -# schedule: -# every: 1d -# manual: true - -objective: | - Find people worth reaching out to about {{ inputs.topic }}, get a conversation - started with each of them, and keep those conversations going. - - **Who you are.** You write on behalf of BoundFlow. Read the `boundflow` skill - before you write to anyone and before you answer a question about it — what it - does, what it does not, and how to talk about it are all there, and inventing any - of it is worse than saying you will find out. - - You are not selling. You are talking to someone who has run into a problem you - have thought about, and the message should read like that. - - Nothing you send reaches anyone until a person has approved it. That is not an - obstacle to work around — it is the point. Write every request and every message - as something worth signing. - - When something is rejected, read the reason before doing anything. If it is a - question, answer it with ask_human and wait — do not re-propose the same thing - and hope. If it is a correction, make the correction. If it is a decision that - this person should not be contacted, leave them alone and say so in your report. - Re-sending an identical request after a rejection is the one thing never to do: - it turns someone's decision into a negotiation. - - **Finding them.** Search first, and contact at most three. Pick the three whose - problem you can speak to most specifically and leave the rest: a fourth message - written to a vaguer signal is worth less than three written to sharp ones. Say - which three you picked and why before you start contacting them. - - **Connecting.** Send each one a connection request with a short, specific note, - in their words rather than yours. One at a time — each is approved separately, - so give whoever is approving a reason to say yes to that particular person. - - **Waiting.** People do not accept straight away, and checking twice in a row - tells you nothing. Wait, then check everyone at once. Waiting costs nothing and - holds nothing open; asking again immediately costs a turn and tells you what you - already knew. If someone has not accepted after several rounds, let them be — - some people never do, and that is a normal outcome rather than a failure. - - **Messaging.** Once someone accepts, write them a first message: short, plainly - written, about the specific problem they described. No pitch. Send it, and a - person signs it off before it goes. - - **Keeping the conversation.** This is the part that matters, and it does not - end. After each round of waiting, read every conversation. When someone has - replied, write a reply of your own and send it — approved like everything else. - Answer what they actually asked. If they asked a question you cannot answer, say - so plainly rather than talking around it. - - Do not send a second message to someone who has not answered your first. An - unanswered message is not an invitation to follow up, it is someone who has not - got to it yet. - - **Stopping.** Keep going while there is anything to wait for: someone who might - still accept, or a conversation that might still get a reply. Stop when every - person has either gone quiet or reached a natural end, and report what happened - with each of them. Report by calling submit_result — writing the summary out as - a message is not reporting it, and the run has no result unless you fill in the - fields. - -wait: - -mcp: - - name: net - command: python - args: ["network.py"] - # env: [NETWORK_TOKEN] - # url: http://localhost:8080/mcp - # headers: - # Authorization: "Bearer ${NET_TOKEN}" - # approval: - # read_only: never - # default: always - tools: - - tool: search_people - on_failure: fail - - tool: connection_status - - tool: conversation - - tool: send_connection_request - approval: always - - tool: send_message - approval: always - # approval_timeout_seconds: 900 - # on_failure: continue - # on_reject: fail - -ask_human: - when: balanced - -# subagents: -# - name: researcher -# description: Reads a lead's history and reports back. -# prompt: Be terse. Report only what you found. -# tools: [net__search_people, net__conversation] -# model: claude-haiku-4-5 - -gate: - on_reject: continue - # tools: [execute, write_file, submit_result, task, start_async_task] - -response_format: - contacted: - type: array - description: One entry per person you contacted. - items: - type: object - properties: - person: - type: string - outcome: - type: string - description: connected | never_accepted | not_approved - exchanged: - type: integer - description: How many messages went each way. - last_reply: - type: string - description: The last thing they said, if anything. - summary: - type: string - description: What happened across the campaign, and what is worth doing next. diff --git a/demo/leads/leads-finder/v1/skills/boundflow/SKILL.md b/demo/leads/leads-finder/v1/skills/boundflow/SKILL.md deleted file mode 100644 index e65df65..0000000 --- a/demo/leads/leads-finder/v1/skills/boundflow/SKILL.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -name: boundflow -description: What BoundFlow and Charter are, what they do about agents running unsupervised, and what they do not do. Read this before writing to anyone, and before answering a question about the product. ---- - -# What you are writing on behalf of - -BoundFlow is a control plane for agents that are already running in production. -Charter is the layer on top where an agent is declared as YAML instead of written -as code. - -The problem both exist for: an agent loop is easy to write and almost impossible -to operate. It spends without a ceiling, it acts without asking, it forgets -everything when the process dies, and when it misbehaves there is no version to -roll back to because the behaviour lived in code that shipped weeks ago. - -## What it actually does - -**Budgets that stop a run.** A ceiling on calls, on spend, and on working time, -enforced while the agent runs rather than reported after. When one is hit the task -ends and the reason names the number that stopped it. - -**Approval gates that park.** A tool can be declared as needing a human. The call -stops, the run *ends* — nothing held open, no process waiting — and resumes when -someone signs off. Days later, on a different machine, is fine. The approver sees -the exact call and its arguments, can edit it, and a rejection reaches the agent -as a reason it can act on. - -**Waiting as a first-class thing.** An agent can stop until a time passes and come -back where it left off. Waiting costs nothing and holds nothing open. - -**Versioned config.** An agent is a version. A task dispatches at the version it -started on, so a rollback doesn't strand work in flight, and what an approver -signed off is tied to the config that produced it. - -**An audit trail.** Every gate, every decision, every rejection reason, per -instance. - -## What it is not - -It is not a framework for building agents, and not a better agent loop. The loop -is deepagents'. It is not a hosted runtime: the worker runs on the customer's -side, so their model key, their tool credentials and their agents' state stay -there. - -## Talking to people about it - -Almost everyone worth writing to has hit exactly one edge of this and does not -care about the rest: - -- an agent that retried something expensive overnight → budgets -- an approval step bolted onto a graph by hand → gates -- state lost on deploy → durability -- a subagent nobody could stop → lifecycle - -Write to the edge they hit. Naming the other three reads as a brochure, and the -one they hit is the only one that proves you read what they wrote. - -Do not claim numbers, customers, benchmarks, or funding. None are given here -because none should be invented. If someone asks something this does not answer, -say you will find out and use `ask_human` — a person is there. diff --git a/demo/leads/network.py b/demo/leads/network.py deleted file mode 100644 index ea2c534..0000000 --- a/demo/leads/network.py +++ /dev/null @@ -1,188 +0,0 @@ -"""A fake professional network, for running the leads pipeline without LinkedIn. - -Real enough to exercise the whole thing — searching, connecting, waiting for -someone to accept, messaging once they have, and a conversation that outlives the -run that started it — with nobody real on the other end. - - python demo/leads/network.py # the worker spawns this itself - -Nobody here answers on a timer. You are the other side: `inbox.py` is where you -accept a connection request or write a reply, and until you do, the agent is -genuinely waiting on a person. That is the whole point of the pipeline, and a -canned reply after twenty seconds doesn't exercise it. - -The agent's view is unchanged either way. It calls `connection_status` and -`conversation` and gets whatever is true, with no idea a human is typing the -other half. - -State lives in SQLite next to this file rather than in memory, because the server, -the worker and your inbox are separate processes and a conversation is supposed to -outlive all of them. Delete network.db to start over. -""" - -import sqlite3 -import time -from pathlib import Path - -from mcp.server.fastmcp import FastMCP -from mcp.types import ToolAnnotations - -READ_ONLY = ToolAnnotations(readOnlyHint=True) -MUTATES = ToolAnnotations(readOnlyHint=False, destructiveHint=False) - -mcp = FastMCP("network") - -DB = Path(__file__).parent / "network.db" - -PEOPLE = [ - ("ade", "Ade Okonkwo", "Staff engineer, fintech", - "agent-governance", - "Wrote a long post about an agent that retried a payout tool 40 times " - "overnight and nobody found out until reconciliation."), - ("mira", "Mira Castellanos", "Founding engineer, devtools", - "agent-governance", - "Asked publicly how anyone puts a human approval step into a LangGraph " - "run without rewriting the whole graph."), - ("dana", "Dana Whitfield", "Head of platform, logistics", - "agent-governance", - "Complained that their agents lose everything on deploy because state " - "lives in process memory."), - ("tomasz", "Tomasz Nowak", "ML engineer, marketplace", - "agent-governance", - "Frustrated that a subagent kept running after the parent task was " - "cancelled and there was no way to stop it."), - ("priya", "Priya Raman", "CTO, healthtech", - "compliance", - "Needs an audit trail showing who approved what before agents touch " - "patient records."), -] - - -def db() -> sqlite3.Connection: - conn = sqlite3.connect(DB) - conn.row_factory = sqlite3.Row - conn.executescript(""" - CREATE TABLE IF NOT EXISTS connections ( - person_id TEXT PRIMARY KEY, - note TEXT NOT NULL, - requested_at REAL NOT NULL, - -- NULL until you accept in inbox.py. Nothing sets this on a timer. - accepted_at REAL - ); - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - person_id TEXT NOT NULL, - direction TEXT NOT NULL, -- 'sent' or 'received' - text TEXT NOT NULL, - at REAL NOT NULL - ); - """) - return conn - - -def person(person_id: str) -> dict: - for pid, name, headline, topic, signal in PEOPLE: - if pid == person_id: - return {"id": pid, "name": name, "headline": headline, - "topic": topic, "signal": signal} - raise ValueError(f"no such person: {person_id}") - - -@mcp.tool(annotations=READ_ONLY) -def search_people(topic: str, limit: int = 5) -> str: - """Find people whose recent activity matches a topic. - - Returns their id, name, headline, and the specific thing they said that made - them a match — which is what an opening message should be written to. - """ - hits = [dict(id=p[0], name=p[1], headline=p[2], signal=p[4]) - for p in PEOPLE if topic.lower() in p[3].lower()][:limit] - if not hits: - return f"No matches for {topic!r}. Known topics: agent-governance, compliance." - return "\n".join( - f"{h['id']}: {h['name']} — {h['headline']}\n signal: {h['signal']}" - for h in hits) - - -@mcp.tool(annotations=MUTATES) -def send_connection_request(person_id: str, note: str) -> str: - """Ask to connect, with a short note. Goes to a real person — worth a human - reading it first.""" - who = person(person_id) - conn = db() - with conn: - existing = conn.execute( - "SELECT requested_at FROM connections WHERE person_id = ?", - (person_id,)).fetchone() - if existing: - return f"Already sent a request to {who['name']}." - conn.execute( - "INSERT INTO connections (person_id, note, requested_at) VALUES (?, ?, ?)", - (person_id, note, time.time())) - return (f"Connection request sent to {who['name']}. They have not accepted " - f"yet — check back later rather than waiting on it.") - - -@mcp.tool(annotations=READ_ONLY) -def connection_status(person_id: str) -> str: - """Whether someone has accepted your request yet. - - Nobody accepts instantly, and some people never do. Both are normal, and - checking again straight away tells you nothing new. - """ - who = person(person_id) - conn = db() - row = conn.execute( - "SELECT requested_at, accepted_at FROM connections WHERE person_id = ?", - (person_id,)).fetchone() - if row is None: - return f"No request has been sent to {who['name']}." - waited = int(time.time() - row["requested_at"]) - if row["accepted_at"] is None: - return (f"{who['name']} has not accepted yet ({waited}s since you asked). " - f"Some people take days, and some never do.") - return f"{who['name']} accepted your request. You can message them now." - - -@mcp.tool(annotations=MUTATES) -def send_message(person_id: str, text: str) -> str: - """Message someone who has accepted. This is the one a human signs off.""" - who = person(person_id) - conn = db() - row = conn.execute( - "SELECT accepted_at FROM connections WHERE person_id = ?", - (person_id,)).fetchone() - if row is None: - raise ValueError(f"not connected to {who['name']} — send a request first") - if row["accepted_at"] is None: - raise ValueError(f"{who['name']} has not accepted yet — cannot message") - with conn: - conn.execute( - "INSERT INTO messages (person_id, direction, text, at) VALUES (?,?,?,?)", - (person_id, "sent", text, time.time())) - return f"Message sent to {who['name']}." - - -@mcp.tool(annotations=READ_ONLY) -def conversation(person_id: str) -> str: - """The whole thread with someone, oldest first. - - A reply appears when they write one, which may be a while and may be never. - An unanswered message is not a failure and it is not a reason to send another. - """ - who = person(person_id) - rows = db().execute( - "SELECT direction, text FROM messages WHERE person_id = ? ORDER BY at, id", - (person_id,)).fetchall() - if not rows: - return f"No messages with {who['name']} yet." - - lines = [f"{'you' if r['direction'] == 'sent' else who['name']}: {r['text']}" - for r in rows] - if rows[-1]["direction"] == "sent": - lines.append(f"({who['name']} has not replied to that yet.)") - return "\n".join(lines) - - -if __name__ == "__main__": - mcp.run(transport="stdio") diff --git a/demo/leads/worker.yaml b/demo/leads/worker.yaml deleted file mode 100644 index c1a9812..0000000 --- a/demo/leads/worker.yaml +++ /dev/null @@ -1,53 +0,0 @@ -apiVersion: charter/v1 -kind: Worker -name: leads - -control_plane: - endpoint: ${BOUNDFLOW_SERVER_ADDRESS} - # Where workers claim tasks — a different address from the control API. - worker_endpoint: ${BOUNDFLOW_WORKER_ADDRESS} - api_key: ${BOUNDFLOW_API_KEY} - tenant: default - # tenant_id: "" - -llm: - provider: anthropic - api_key: ${ANTHROPIC_API_KEY} - -store: - url: ${CHARTER_STORE_URL} - -agents_dir: ./ - -serves: - - agent: leads-finder - versions: [1] - # repository: ghcr.io/acme/agents # pull v1 from the registry instead - -# notifications: -# channels: -# - name: terminal -# kind: webhook -# url: http://localhost:8899 -# secret: ${WEBHOOK_SECRET} -# timeout_seconds: 5 -# max_attempts: 3 -# - name: ops -# kind: telegram -# url: https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage -# chat_id: "-1001234567890" -# routes: -# - channel: terminal -# agent: leads-finder -# events: [approval_requested, input_requested] - -# Every model call and tool call, with prompts and results, written where this -# says. Use kind: otel with an endpoint to send them to your own OTLP backend. -# trace_sink: -# kind: jsonl -# path: ./traces.jsonl - -model_pricing: - claude-haiku-4-5: - input: 0.8 - output: 4.0 diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..8bbab60 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,46 @@ +# Examples + +Two agents over one toy support desk. `desk.py` is a small MCP server with four +tickets and the charges behind them, so both run with nothing but a model key. + + refund-triage reads a ticket, decides, and refunds. The refund stops + for a human before it goes through. + ticket-summarizer reads every open ticket and reports what needs attention. + Two versions, so a rollback has somewhere to go. + +## Running them + +You need a control plane and the environment from the +[Quickstart](../README.md#quickstart), plus `ANTHROPIC_API_KEY`. From this +directory: + + charter agent create refund-triage + charter apply . + charter worker . + +Then, from another terminal: + + charter run refund-triage --instance --ticket_id T-1041 + +The agent reads the ticket, looks up the charge, and asks to refund it. Nothing +holds your terminal open while it waits: + + charter pending refund-triage --instance + +That prints the call it wants to make and the commands that answer it. Approve, +and the refund goes through and the task finishes. Reject, and the agent is told +and carries on without it. + +`charter ui` shows the same thing in a browser, across every agent at once. + +## What each file is for + +`v1.yaml` is behaviour, and it is versioned: you write a v2 rather than editing +it. `runtime.yaml` is what one task may spend and what the agent may reach. +`lifecycle.yaml` is what the control plane does to the agent between tasks. +Those last two are re-applied on every `charter apply`, so a ceiling can be +lowered without cutting a release. + +`worker.yaml` is the deployment: which control plane, whose credentials, which +agents this process serves. The notification and tracing blocks are commented +out, because uncommented they need the environment variables they name. diff --git a/examples/desk.py b/examples/desk.py new file mode 100644 index 0000000..96c3389 --- /dev/null +++ b/examples/desk.py @@ -0,0 +1,95 @@ +"""A toy support desk, so the examples run without a Zendesk or a Stripe account. + +Two agents share it: `ticket-summarizer` reads, `refund-triage` reads and refunds. +One small domain across both, so what differs between the examples is the +capability being shown rather than the scenario. + + python examples/desk.py # the worker spawns this itself + +State is in memory. Each worker gets a fresh desk, which is what you want from an +example: the same four tickets every run, and a refund that is gone when you +restart. +""" + +from mcp.server.fastmcp import FastMCP +from mcp.types import ToolAnnotations + +READ_ONLY = ToolAnnotations(readOnlyHint=True) +MUTATES = ToolAnnotations(readOnlyHint=False, destructiveHint=False) + +mcp = FastMCP("desk") + +TICKETS = { + "T-1041": { + "subject": "Charged twice for one order", + "body": "My card was charged twice on the 3rd. Same amount, same order.", + "charge_id": "ch_88213", + "opened": "2 days ago", + }, + "T-1042": { + "subject": "Package never arrived", + "body": "Tracking says delivered but nothing came. Neighbours checked too.", + "charge_id": "ch_88410", + "opened": "6 hours ago", + }, + "T-1043": { + "subject": "Wrong size, want to exchange", + "body": "Ordered a medium, received a small. Happy to swap, not refund.", + "charge_id": "ch_88455", + "opened": "1 day ago", + }, + "T-1044": { + "subject": "Please cancel my subscription", + "body": "Cancel from next month. No refund needed for this one.", + "charge_id": "ch_88501", + "opened": "4 days ago", + }, +} + +CHARGES = { + "ch_88213": {"amount_usd": 48.00, "description": "Order #4417", "refunded_usd": 0.0}, + "ch_88410": {"amount_usd": 132.50, "description": "Order #4420", "refunded_usd": 0.0}, + "ch_88455": {"amount_usd": 61.00, "description": "Order #4425", "refunded_usd": 0.0}, + "ch_88501": {"amount_usd": 19.00, "description": "Subscription, March", "refunded_usd": 0.0}, +} + + +@mcp.tool(annotations=READ_ONLY) +def search_tickets() -> list[dict]: + """Every open ticket, newest concern first.""" + return [{"ticket_id": k, **v} for k, v in TICKETS.items()] + + +@mcp.tool(annotations=READ_ONLY) +def get_ticket(ticket_id: str) -> dict: + """One ticket, including the charge behind it.""" + if ticket_id not in TICKETS: + raise ValueError(f"no ticket {ticket_id}. Try one of: {', '.join(TICKETS)}") + return {"ticket_id": ticket_id, **TICKETS[ticket_id]} + + +@mcp.tool(annotations=READ_ONLY) +def get_charge(charge_id: str) -> dict: + """What was charged, and what has already been refunded against it.""" + if charge_id not in CHARGES: + raise ValueError(f"no charge {charge_id}") + return {"charge_id": charge_id, **CHARGES[charge_id]} + + +@mcp.tool(annotations=MUTATES) +def create_refund(charge_id: str, amount_usd: float) -> dict: + """Refund against a charge. Gated: a person approves this before it runs.""" + if charge_id not in CHARGES: + raise ValueError(f"no charge {charge_id}") + charge = CHARGES[charge_id] + outstanding = charge["amount_usd"] - charge["refunded_usd"] + if amount_usd > outstanding: + raise ValueError( + f"{amount_usd} exceeds the {outstanding} still refundable on {charge_id}") + charge["refunded_usd"] += amount_usd + return {"charge_id": charge_id, "refunded_usd": amount_usd, + "remaining_usd": charge["amount_usd"] - charge["refunded_usd"]} + + +if __name__ == "__main__": + mcp.run() diff --git a/examples/refund-triage/lifecycle.yaml b/examples/refund-triage/lifecycle.yaml index 6b1f8a7..b74ec68 100644 --- a/examples/refund-triage/lifecycle.yaml +++ b/examples/refund-triage/lifecycle.yaml @@ -13,5 +13,5 @@ rules: - when: { metric: approval_rejections, threshold: 3 } then: { set_version: { target: 1 } } - - when: { metric: tool_failures, threshold: 3, tool: stripe__create_refund } + - when: { metric: tool_failures, threshold: 3, tool: desk__create_refund } then: { pause: { window: 10 } } diff --git a/examples/refund-triage/runtime.yaml b/examples/refund-triage/runtime.yaml index fc7e167..6d4b875 100644 --- a/examples/refund-triage/runtime.yaml +++ b/examples/refund-triage/runtime.yaml @@ -8,9 +8,9 @@ per_run: max_cost_usd: 0.30 max_llm_calls: 40 tool_call_limits: - - tool: stripe__get_charge + - tool: desk__get_charge max_calls: 5 - - tool: zendesk__search_tickets + - tool: desk__get_ticket max_calls: 10 # Covers every way the harness can read a file, so the cap survives it shipping # a fourth one. Capping `read_file` alone would not. diff --git a/examples/refund-triage/v1.yaml b/examples/refund-triage/v1.yaml index 04e1553..2ccee38 100644 --- a/examples/refund-triage/v1.yaml +++ b/examples/refund-triage/v1.yaml @@ -21,7 +21,7 @@ inputs: ticket_id: type: string required: true - description: Zendesk ticket to resolve. + description: Ticket to resolve. The desk has T-1041 to T-1044. max_refund_usd: type: number default: 100 @@ -32,23 +32,13 @@ inputs: enum: [low, normal, urgent] mcp: - - name: zendesk - command: npx - args: ["-y", "@zendesk/mcp"] - env: [ZENDESK_SUBDOMAIN, ZENDESK_API_TOKEN] + - name: desk + command: python + args: [desk.py] tools: - tool: get_ticket on_failure: fail - - tool: search_tickets - - tool: add_internal_note - - tool: close_ticket - - - name: stripe - url: https://mcp.stripe.com - env: [STRIPE_API_KEY] - tools: - tool: get_charge - - tool: list_refunds - tool: create_refund approval: always on_failure: fail diff --git a/examples/refund-triage/v1/skills/refund-policy/SKILL.md b/examples/refund-triage/v1/skills/refund-policy/SKILL.md new file mode 100644 index 0000000..f48abac --- /dev/null +++ b/examples/refund-triage/v1/skills/refund-policy/SKILL.md @@ -0,0 +1,33 @@ +--- +name: refund-policy +description: When this desk refunds, when it does not, and what to say to the approver. Read before proposing any refund. +--- + +# When a refund is warranted + +Refund when the customer was charged for something they did not receive, or +charged more than once for the same order. Both are errors on our side and the +money is not ours to keep. + +Do not refund a change of mind, a size or colour swap, or a subscription the +customer forgot to cancel. Those are exchanges or cancellations, and a refund is +not the instrument. + +## Amounts + +Refund the amount actually charged, never a round number near it. If only part of +an order is affected, refund that part. + +If the right amount is above the ceiling you were given, propose the ceiling and +say in your resolution that it was capped. Do not split one refund across +several calls to get around it. + +## What the approver reads + +The sentence you write is what a person sees before deciding, and often the only +thing they see. Say what happened, what you are about to do, and the evidence +that connects them. + +Good: "Charged twice for order #4417 on the 3rd, $48.00 each. Refunding one." + +Bad: "Customer requested a refund and it seems reasonable." diff --git a/examples/ticket-summarizer/runtime.yaml b/examples/ticket-summarizer/runtime.yaml index 86f9ae7..27297d6 100644 --- a/examples/ticket-summarizer/runtime.yaml +++ b/examples/ticket-summarizer/runtime.yaml @@ -6,5 +6,5 @@ agent: ticket-summarizer per_run: max_cost_usd: 0.15 tool_call_limits: - - tool: zendesk__get_ticket + - tool: desk__get_ticket max_calls: 25 diff --git a/examples/ticket-summarizer/v1.yaml b/examples/ticket-summarizer/v1.yaml index e682b2e..a2f6ae5 100644 --- a/examples/ticket-summarizer/v1.yaml +++ b/examples/ticket-summarizer/v1.yaml @@ -12,10 +12,9 @@ objective: | Lead with anything that has been waiting longest. mcp: - - name: zendesk - command: npx - args: ["-y", "@zendesk/mcp"] - env: [ZENDESK_SUBDOMAIN, ZENDESK_API_TOKEN] + - name: desk + command: python + args: [desk.py] tools: - tool: search_tickets on_failure: fail diff --git a/examples/ticket-summarizer/v2.yaml b/examples/ticket-summarizer/v2.yaml index 088a57c..f11750d 100644 --- a/examples/ticket-summarizer/v2.yaml +++ b/examples/ticket-summarizer/v2.yaml @@ -15,10 +15,9 @@ schedule: every: 15m mcp: - - name: zendesk - command: npx - args: ["-y", "@zendesk/mcp"] - env: [ZENDESK_SUBDOMAIN, ZENDESK_API_TOKEN] + - name: desk + command: python + args: [desk.py] tools: - tool: search_tickets on_failure: fail diff --git a/examples/worker.yaml b/examples/worker.yaml index 4cda6eb..396dc6d 100644 --- a/examples/worker.yaml +++ b/examples/worker.yaml @@ -48,9 +48,10 @@ notifications: channel: finance - channel: oncall -trace_sink: - kind: otel - endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT} +# Needs the endpoint it names, so it is left off. +# trace_sink: +# kind: otel +# endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT} model_pricing: claude-haiku-4-5: { input: 0.80, output: 4.00 } diff --git a/tests/test_agent_config.py b/tests/test_agent_config.py index fbf89b9..cbaed61 100644 --- a/tests/test_agent_config.py +++ b/tests/test_agent_config.py @@ -24,11 +24,11 @@ def test_example_parses(): def test_derived_views(): cfg = AgentConfig.model_validate(load()) - assert cfg.gated_tools == ["stripe__create_refund"] - assert "zendesk__get_ticket" in cfg.inline_tools - assert "stripe__create_refund" not in cfg.inline_tools - assert cfg.fail_fast_tools == {"zendesk__get_ticket", "stripe__create_refund"} - assert len(cfg.all_tools) == 7 + assert cfg.gated_tools == ["desk__create_refund"] + assert "desk__get_ticket" in cfg.inline_tools + assert "desk__create_refund" not in cfg.inline_tools + assert cfg.fail_fast_tools == {"desk__get_ticket", "desk__create_refund"} + assert len(cfg.all_tools) == 3 def test_invoke_mode_is_derived(): @@ -75,7 +75,9 @@ def test_neither_command_nor_url_rejected(self): def test_http_url_rejected(self): raw = load() - raw["mcp"][1]["url"] = "http://mcp.stripe__com" + raw["mcp"][0].pop("command") + raw["mcp"][0].pop("args") + raw["mcp"][0]["url"] = "http://mcp.example.com" with pytest.raises(ValidationError, match="https"): AgentConfig.model_validate(raw) @@ -83,7 +85,7 @@ def test_env_value_rejected(self): """`env` takes variable NAMES — this file is committed and immutable, so a literal secret here would live forever.""" raw = load() - raw["mcp"][1]["env"] = ["sk_live_abc123"] + raw["mcp"][0]["env"] = ["sk_live_abc123"] with pytest.raises(ValidationError, match="variable NAME"): AgentConfig.model_validate(raw) diff --git a/tests/test_artifact.py b/tests/test_artifact.py index 300425b..e788613 100644 --- a/tests/test_artifact.py +++ b/tests/test_artifact.py @@ -17,7 +17,7 @@ from charter.artifact import pack from charter.config.loader import load_agent -DEMO = Path(__file__).parent.parent / "demo" / "leads" / "leads-finder" +DEMO = Path(__file__).parent.parent / "examples" / "refund-triage" def bundle(): @@ -90,4 +90,4 @@ def test_the_tag_comes_from_the_config_not_the_caller(): assert packed.tag == "v1" assert packed.reference("ghcr.io/acme/agents") == \ - "ghcr.io/acme/agents/leads-finder:v1" + "ghcr.io/acme/agents/refund-triage:v1" diff --git a/tests/test_compile.py b/tests/test_compile.py index e65cc65..dc6b7de 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -44,8 +44,8 @@ def test_runtime_policy(): assert p.max_tokens_per_call == 1024 assert p.max_call_seconds == 60 assert {l.tool: l.max_calls for l in p.tool_call_limits} == { - "stripe__get_charge": 5, - "zendesk__search_tickets": 10, + "desk__get_charge": 5, + "desk__get_ticket": 10, } @@ -86,7 +86,7 @@ def test_tool_failures_renames_to_boundflows_misnomer(): a ratio. BoundFlow's metric is named TOOL_FAILURE_RATE.""" rule = next(r for r in refund().workflow_rules if r.metric == WorkflowMetric.TOOL_FAILURE_RATE) - assert rule.tool == "stripe__create_refund" + assert rule.tool == "desk__create_refund" assert rule.threshold == 3 diff --git a/tests/test_docs.py b/tests/test_docs.py index bdb9eba..b239f34 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -17,7 +17,7 @@ from charter.config.worker import WorkerManifest ROOT = Path(__file__).parent.parent -DOCS = ["README.md", "DESIGN.md", "demo/leads/README.md", "deploy/README.md"] +DOCS = ["README.md", "DESIGN.md", "examples/README.md", "deploy/README.md"] def _model_for(doc: dict): diff --git a/tests/test_harness_bounds.py b/tests/test_harness_bounds.py index 72e990f..617f6f6 100644 --- a/tests/test_harness_bounds.py +++ b/tests/test_harness_bounds.py @@ -113,7 +113,7 @@ def agent_with(subagents): from charter.config.agent import AgentConfig from pathlib import Path raw = yaml.safe_load( - (Path(__file__).parent.parent / "demo/leads/leads-finder/v1.yaml").read_text()) + (Path(__file__).parent.parent / "examples/refund-triage/v1.yaml").read_text()) raw["subagents"] = subagents return AgentConfig.model_validate(raw) @@ -133,7 +133,7 @@ def test_a_declared_subagent_carries_the_same_bounds(): cfg = agent_with([{"name": "researcher", "description": "Reads."}]) gov = governor(allowed_capabilities=["read", "spawn"], allowed_tools=[]) - spec = declared_subagents(cfg, fake_tools("net__search_people"), gov, {})[0] + spec = declared_subagents(cfg, fake_tools("desk__get_ticket"), gov, {})[0] assert offer(spec["middleware"], "write_file") == "refused" assert offer(spec["middleware"], "read_file") == "allowed" @@ -143,12 +143,12 @@ def test_a_narrower_tool_list_is_honoured(): from charter.harness.durable import declared_subagents cfg = agent_with([{"name": "researcher", "description": "Reads.", - "tools": ["net__search_people"]}]) - tools = fake_tools("net__search_people", "net__send_message") + "tools": ["desk__get_ticket"]}]) + tools = fake_tools("desk__get_ticket", "desk__get_charge") spec = declared_subagents(cfg, tools, governor(), {})[0] - assert [t.name for t in spec["tools"]] == ["net__search_people"] + assert [t.name for t in spec["tools"]] == ["desk__get_ticket"] def test_declaring_no_tools_means_the_parents_whole_set(): @@ -171,7 +171,7 @@ def test_a_subagent_cannot_reach_further_than_its_parent(): with _pytest.raises(ValidationError, match="does not declare"): agent_with([{"name": "researcher", "description": "Reads.", - "tools": ["net__nonexistent"]}]) + "tools": ["desk__nonexistent"]}]) def test_general_purpose_cannot_be_redeclared(): diff --git a/tests/test_loader.py b/tests/test_loader.py index 56ceab7..3355bc2 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -65,14 +65,14 @@ def test_runtime_agent_mismatch(self, project): def test_tool_call_limit_for_undeclared_tool(self, project): def mutate(raw): - raw["per_run"]["tool_call_limits"][0]["tool"] = "stripe__nonexistent" + raw["per_run"]["tool_call_limits"][0]["tool"] = "desk__nonexistent" edit(project / "refund-triage" / "runtime.yaml", mutate) with pytest.raises(ConfigError, match="no version of this agent declares"): load_agent(project / "refund-triage") def test_lifecycle_rule_for_undeclared_tool(self, project): def mutate(raw): - raw["rules"][-1]["when"]["tool"] = "stripe__nonexistent" + raw["rules"][-1]["when"]["tool"] = "desk__nonexistent" edit(project / "refund-triage" / "lifecycle.yaml", mutate) with pytest.raises(ConfigError, match="no version of this agent declares"): load_agent(project / "refund-triage") diff --git a/tests/test_loop.py b/tests/test_loop.py index 8c8fd11..a8df633 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -158,8 +158,8 @@ def test_gated_tools_become_interrupts_not_omissions(): rather than reject and hope the next attempt is right.""" cfg = load_agent(EXAMPLES / "refund-triage").latest gates = interrupt_on(cfg) - assert set(gates) == {"stripe__create_refund"} - assert gates["stripe__create_refund"]["allowed_decisions"] == [ + assert set(gates) == {"desk__create_refund"} + assert gates["desk__create_refund"]["allowed_decisions"] == [ "approve", "edit", "reject"] @@ -195,16 +195,16 @@ def test_a_pending_action_parks_the_task(): cfg, loop = loop_for() interrupt = {"__interrupt__": [type("I", (), { "value": {"action_requests": [ - {"name": "stripe__create_refund", "args": {"amount": 40}, + {"name": "desk__create_refund", "args": {"amount": 40}, "description": "Refund $40 to the customer"}]}, "id": "int-1"})()]} ctx = FakeCtx(results=[FakeResult(interrupt)]) out = run(loop.entry(ctx)) assert isinstance(out, AwaitApproval) - assert "stripe__create_refund" in out.justification + assert "desk__create_refund" in out.justification assert "Refund $40 to the customer" in out.justification - assert out.metadata["tool"] == "stripe__create_refund" + assert out.metadata["tool"] == "desk__create_refund" assert out.timeout == loop.runtime.authority.approval_timeout_seconds assert ctx.context[K_GATES] == 1 @@ -434,7 +434,7 @@ def test_an_ordinary_tool_failure_does_not_end_the_task(): _, loop = loop_for() out = run(loop.entry(FakeCtx(results=[ FakeResult({"resolution": "worked around it"}, - tool_failures={"desk__get_ticket": 2})]))) + tool_failures={"desk__get_charge": 2})]))) assert isinstance(out, Complete) assert "failed" not in out.result @@ -531,7 +531,7 @@ def _loop(self, **gate): return Loop(bundle.latest, bundle.runtime, tools=empty, chat_model=lambda m: object(), store_url="postgresql://unused") - def _interrupt(self, tool="stripe__create_refund"): + def _interrupt(self, tool="desk__create_refund"): return {"__interrupt__": [type("I", (), { "value": {"action_requests": [ {"name": tool, "args": {"amount": 40}, "description": "d"}]}, @@ -562,13 +562,13 @@ def test_on_reject_fail_stops_the_task(self): scanning statuses never learns the thing it existed to do didn't happen.""" loop = self._loop(on_reject="fail") ctx = FakeCtx(context={K_DECISION: "reject", - "_gated_tool": "stripe__create_refund"}, + "_gated_tool": "desk__create_refund"}, approval_reason="too much", results=[FakeResult({"resolution": "unused"})]) out = run(loop.entry(ctx)) assert out.result["failed"] is True - assert "stripe__create_refund" in out.result["reason"] + assert "desk__create_refund" in out.result["reason"] assert "too much" in out.result["reason"] def test_an_unanswered_gate_under_fail_says_so(self): @@ -608,7 +608,7 @@ def test_declared_mcp_tools_still_gate_themselves(self): """Two mechanisms, one for each declaration site — a tool that declares `approval: always` shouldn't also need naming here.""" cfg = self._cfg([]) - assert "stripe__create_refund" in interrupt_on(cfg) + assert "desk__create_refund" in interrupt_on(cfg) def test_a_typo_is_refused_rather_than_gating_nothing(self): from charter.config.agent import Gate From d6ca6cd8bdaca4bf92760c2fe7af072fd2049392 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Mon, 7 Sep 2026 12:30:48 -0400 Subject: [PATCH 03/49] Say the examples need Charter's interpreter, not just its CLI The worker spawns the tool server as `python desk.py`. With the venv inactive that resolves elsewhere, mcp is missing, and the agent is quarantined at boot. --- examples/README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/examples/README.md b/examples/README.md index 8bbab60..7ccb043 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,8 +11,15 @@ tickets and the charges behind them, so both run with nothing but a model key. ## Running them You need a control plane and the environment from the -[Quickstart](../README.md#quickstart), plus `ANTHROPIC_API_KEY`. From this -directory: +[Quickstart](../README.md#quickstart), plus `ANTHROPIC_API_KEY`. + +Run these with Charter's environment active, not just `charter` on your path. The +worker spawns the tool server as `python desk.py`, so bare `python` has to be the +interpreter Charter is installed in: + + source .venv/bin/activate + +From this directory: charter agent create refund-triage charter apply . From 914560d348a4bc29f491a0d0acac84c9aedb95ee Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Mon, 7 Sep 2026 12:37:38 -0400 Subject: [PATCH 04/49] Serve the examples' MCP server over HTTP Spawned as `python desk.py`, the server ran under whatever `python` the worker's PATH resolved to. With the venv inactive that is a different interpreter, `mcp` is missing, and both agents quarantine at boot. It now serves streamable-http on 127.0.0.1:8931 and the agents name it by URL, which is how an MCP server is usually reached anyway. You start it yourself, as its own step in the README. The worker boots clean with no venv active. Loopback http is already allowed by the url rule, so nothing in the schema changed. The three McpServer tests that assumed the example spawns a process now assume it names a URL. --- examples/README.md | 14 ++++++++------ examples/desk.py | 10 +++++++--- examples/refund-triage/v1.yaml | 3 +-- examples/ticket-summarizer/v1.yaml | 3 +-- examples/ticket-summarizer/v2.yaml | 3 +-- tests/test_agent_config.py | 9 ++++----- 6 files changed, 22 insertions(+), 20 deletions(-) diff --git a/examples/README.md b/examples/README.md index 7ccb043..20207dc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,19 +13,18 @@ tickets and the charges behind them, so both run with nothing but a model key. You need a control plane and the environment from the [Quickstart](../README.md#quickstart), plus `ANTHROPIC_API_KEY`. -Run these with Charter's environment active, not just `charter` on your path. The -worker spawns the tool server as `python desk.py`, so bare `python` has to be the -interpreter Charter is installed in: +Start the desk. It serves MCP over HTTP on port 8931, and both agents reach it by +URL: - source .venv/bin/activate + python examples/desk.py -From this directory: +In a second terminal, from this directory, bring up an agent and a worker: charter agent create refund-triage charter apply . charter worker . -Then, from another terminal: +In a third, give it a ticket: charter run refund-triage --instance --ticket_id T-1041 @@ -40,6 +39,9 @@ and carries on without it. `charter ui` shows the same thing in a browser, across every agent at once. +The desk has four tickets, T-1041 to T-1044. One is a duplicate charge, one is a +size exchange the refund policy says not to refund. + ## What each file is for `v1.yaml` is behaviour, and it is versioned: you write a v2 rather than editing diff --git a/examples/desk.py b/examples/desk.py index 96c3389..9ddf01d 100644 --- a/examples/desk.py +++ b/examples/desk.py @@ -4,7 +4,11 @@ One small domain across both, so what differs between the examples is the capability being shown rather than the scenario. - python examples/desk.py # the worker spawns this itself + python examples/desk.py # start it yourself, in its own terminal + +It serves MCP over HTTP on localhost:8931, which is how a real MCP server is +usually reached. The agents name it by URL, so nothing here depends on which +interpreter the worker happens to run. State is in memory. Each worker gets a fresh desk, which is what you want from an example: the same four tickets every run, and a refund that is gone when you @@ -17,7 +21,7 @@ READ_ONLY = ToolAnnotations(readOnlyHint=True) MUTATES = ToolAnnotations(readOnlyHint=False, destructiveHint=False) -mcp = FastMCP("desk") +mcp = FastMCP("desk", host="127.0.0.1", port=8931) TICKETS = { "T-1041": { @@ -92,4 +96,4 @@ def create_refund(charge_id: str, amount_usd: float) -> dict: if __name__ == "__main__": - mcp.run() + mcp.run(transport="streamable-http") diff --git a/examples/refund-triage/v1.yaml b/examples/refund-triage/v1.yaml index 2ccee38..963e310 100644 --- a/examples/refund-triage/v1.yaml +++ b/examples/refund-triage/v1.yaml @@ -33,8 +33,7 @@ inputs: mcp: - name: desk - command: python - args: [desk.py] + url: http://localhost:8931/mcp tools: - tool: get_ticket on_failure: fail diff --git a/examples/ticket-summarizer/v1.yaml b/examples/ticket-summarizer/v1.yaml index a2f6ae5..b76f2ef 100644 --- a/examples/ticket-summarizer/v1.yaml +++ b/examples/ticket-summarizer/v1.yaml @@ -13,8 +13,7 @@ objective: | mcp: - name: desk - command: python - args: [desk.py] + url: http://localhost:8931/mcp tools: - tool: search_tickets on_failure: fail diff --git a/examples/ticket-summarizer/v2.yaml b/examples/ticket-summarizer/v2.yaml index f11750d..61f5a97 100644 --- a/examples/ticket-summarizer/v2.yaml +++ b/examples/ticket-summarizer/v2.yaml @@ -16,8 +16,7 @@ schedule: mcp: - name: desk - command: python - args: [desk.py] + url: http://localhost:8931/mcp tools: - tool: search_tickets on_failure: fail diff --git a/tests/test_agent_config.py b/tests/test_agent_config.py index cbaed61..f1a8edd 100644 --- a/tests/test_agent_config.py +++ b/tests/test_agent_config.py @@ -62,21 +62,20 @@ def test_non_input_namespace_rejected(self): class TestMcpServer: def test_command_and_url_together_rejected(self): raw = load() - raw["mcp"][0]["url"] = "https://example.com" + raw["mcp"][0]["command"] = "python" with pytest.raises(ValidationError, match="exactly one of"): AgentConfig.model_validate(raw) def test_neither_command_nor_url_rejected(self): raw = load() - raw["mcp"][0].pop("command") - raw["mcp"][0].pop("args") + raw["mcp"][0].pop("url") with pytest.raises(ValidationError, match="exactly one of"): AgentConfig.model_validate(raw) def test_http_url_rejected(self): + """Loopback is the exception; anything else over http carries a token in + cleartext.""" raw = load() - raw["mcp"][0].pop("command") - raw["mcp"][0].pop("args") raw["mcp"][0]["url"] = "http://mcp.example.com" with pytest.raises(ValidationError, match="https"): AgentConfig.model_validate(raw) From 66e93829c11fc23f880dabed23363c3d4b38d52a Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Mon, 7 Sep 2026 12:51:52 -0400 Subject: [PATCH 05/49] Call the example server what it is 'desk' is help-desk jargon, and it leaked into every tool name as desk__create_refund. The server is support_server.py, the server is named support, and the tools read support__get_ticket and support__create_refund. tests/e2e/test_live.py keeps 'desk.get_ticket' in its docstring: that is the actual name from the incident it describes. --- README.md | 2 +- examples/README.md | 13 +++++----- examples/refund-triage/lifecycle.yaml | 2 +- examples/refund-triage/runtime.yaml | 4 +-- examples/refund-triage/v1.yaml | 4 +-- .../v1/skills/refund-policy/SKILL.md | 2 +- examples/{desk.py => support_server.py} | 11 ++++---- examples/ticket-summarizer/runtime.yaml | 2 +- examples/ticket-summarizer/v1.yaml | 2 +- examples/ticket-summarizer/v2.yaml | 2 +- tests/e2e/harness.py | 2 +- tests/e2e/test_failures.py | 10 +++---- tests/e2e/test_lifecycle.py | 12 ++++----- tests/e2e/test_live.py | 4 +-- tests/test_agent_config.py | 8 +++--- tests/test_cli.py | 2 +- tests/test_compile.py | 6 ++--- tests/test_harness_bounds.py | 10 +++---- tests/test_loader.py | 4 +-- tests/test_loop.py | 26 +++++++++---------- tests/test_subagents.py | 2 +- 21 files changed, 65 insertions(+), 65 deletions(-) rename examples/{desk.py => support_server.py} (90%) diff --git a/README.md b/README.md index 82295d7..b8c3232 100644 --- a/README.md +++ b/README.md @@ -288,7 +288,7 @@ through their workers and the control plane whether or not the CLI is installed. - [DESIGN.md](DESIGN.md): every field of every file, and the decisions behind them - [deploy/](deploy/): running workers as containers, and a control plane locally -- [examples/](examples/): two agents over a toy support desk, one of them gating +- [examples/](examples/): two agents over a toy support system, one of them gating a refund. They run with nothing but a model key ## Development diff --git a/examples/README.md b/examples/README.md index 20207dc..2cf47b1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,7 +1,8 @@ # Examples -Two agents over one toy support desk. `desk.py` is a small MCP server with four -tickets and the charges behind them, so both run with nothing but a model key. +Two agents over one toy support system. `support_server.py` is a small MCP server +with four tickets and the charges behind them, so both run with nothing but a +model key. refund-triage reads a ticket, decides, and refunds. The refund stops for a human before it goes through. @@ -13,10 +14,10 @@ tickets and the charges behind them, so both run with nothing but a model key. You need a control plane and the environment from the [Quickstart](../README.md#quickstart), plus `ANTHROPIC_API_KEY`. -Start the desk. It serves MCP over HTTP on port 8931, and both agents reach it by -URL: +Start the support server. It serves MCP over HTTP on port 8931, and both agents +reach it by URL: - python examples/desk.py + python examples/support_server.py In a second terminal, from this directory, bring up an agent and a worker: @@ -39,7 +40,7 @@ and carries on without it. `charter ui` shows the same thing in a browser, across every agent at once. -The desk has four tickets, T-1041 to T-1044. One is a duplicate charge, one is a +There are four tickets, T-1041 to T-1044. One is a duplicate charge, one is a size exchange the refund policy says not to refund. ## What each file is for diff --git a/examples/refund-triage/lifecycle.yaml b/examples/refund-triage/lifecycle.yaml index b74ec68..6207685 100644 --- a/examples/refund-triage/lifecycle.yaml +++ b/examples/refund-triage/lifecycle.yaml @@ -13,5 +13,5 @@ rules: - when: { metric: approval_rejections, threshold: 3 } then: { set_version: { target: 1 } } - - when: { metric: tool_failures, threshold: 3, tool: desk__create_refund } + - when: { metric: tool_failures, threshold: 3, tool: support__create_refund } then: { pause: { window: 10 } } diff --git a/examples/refund-triage/runtime.yaml b/examples/refund-triage/runtime.yaml index 6d4b875..e56ef60 100644 --- a/examples/refund-triage/runtime.yaml +++ b/examples/refund-triage/runtime.yaml @@ -8,9 +8,9 @@ per_run: max_cost_usd: 0.30 max_llm_calls: 40 tool_call_limits: - - tool: desk__get_charge + - tool: support__get_charge max_calls: 5 - - tool: desk__get_ticket + - tool: support__get_ticket max_calls: 10 # Covers every way the harness can read a file, so the cap survives it shipping # a fourth one. Capping `read_file` alone would not. diff --git a/examples/refund-triage/v1.yaml b/examples/refund-triage/v1.yaml index 963e310..950814e 100644 --- a/examples/refund-triage/v1.yaml +++ b/examples/refund-triage/v1.yaml @@ -21,7 +21,7 @@ inputs: ticket_id: type: string required: true - description: Ticket to resolve. The desk has T-1041 to T-1044. + description: Ticket to resolve. T-1041 to T-1044 exist. max_refund_usd: type: number default: 100 @@ -32,7 +32,7 @@ inputs: enum: [low, normal, urgent] mcp: - - name: desk + - name: support url: http://localhost:8931/mcp tools: - tool: get_ticket diff --git a/examples/refund-triage/v1/skills/refund-policy/SKILL.md b/examples/refund-triage/v1/skills/refund-policy/SKILL.md index f48abac..a5cc537 100644 --- a/examples/refund-triage/v1/skills/refund-policy/SKILL.md +++ b/examples/refund-triage/v1/skills/refund-policy/SKILL.md @@ -1,6 +1,6 @@ --- name: refund-policy -description: When this desk refunds, when it does not, and what to say to the approver. Read before proposing any refund. +description: When we refund, when we do not, and what to say to the approver. Read before proposing any refund. --- # When a refund is warranted diff --git a/examples/desk.py b/examples/support_server.py similarity index 90% rename from examples/desk.py rename to examples/support_server.py index 9ddf01d..2a3fe1b 100644 --- a/examples/desk.py +++ b/examples/support_server.py @@ -1,18 +1,17 @@ -"""A toy support desk, so the examples run without a Zendesk or a Stripe account. +"""A toy support system, so the examples run without a Zendesk or a Stripe account. Two agents share it: `ticket-summarizer` reads, `refund-triage` reads and refunds. One small domain across both, so what differs between the examples is the capability being shown rather than the scenario. - python examples/desk.py # start it yourself, in its own terminal + python examples/support_server.py # start it yourself, in its own terminal It serves MCP over HTTP on localhost:8931, which is how a real MCP server is usually reached. The agents name it by URL, so nothing here depends on which interpreter the worker happens to run. -State is in memory. Each worker gets a fresh desk, which is what you want from an -example: the same four tickets every run, and a refund that is gone when you -restart. +State is in memory. Restarting it puts the same four tickets back, and forgets +any refund, which is what you want from an example. """ from mcp.server.fastmcp import FastMCP @@ -21,7 +20,7 @@ READ_ONLY = ToolAnnotations(readOnlyHint=True) MUTATES = ToolAnnotations(readOnlyHint=False, destructiveHint=False) -mcp = FastMCP("desk", host="127.0.0.1", port=8931) +mcp = FastMCP("support", host="127.0.0.1", port=8931) TICKETS = { "T-1041": { diff --git a/examples/ticket-summarizer/runtime.yaml b/examples/ticket-summarizer/runtime.yaml index 27297d6..00515c4 100644 --- a/examples/ticket-summarizer/runtime.yaml +++ b/examples/ticket-summarizer/runtime.yaml @@ -6,5 +6,5 @@ agent: ticket-summarizer per_run: max_cost_usd: 0.15 tool_call_limits: - - tool: desk__get_ticket + - tool: support__get_ticket max_calls: 25 diff --git a/examples/ticket-summarizer/v1.yaml b/examples/ticket-summarizer/v1.yaml index b76f2ef..ca0a805 100644 --- a/examples/ticket-summarizer/v1.yaml +++ b/examples/ticket-summarizer/v1.yaml @@ -12,7 +12,7 @@ objective: | Lead with anything that has been waiting longest. mcp: - - name: desk + - name: support url: http://localhost:8931/mcp tools: - tool: search_tickets diff --git a/examples/ticket-summarizer/v2.yaml b/examples/ticket-summarizer/v2.yaml index 61f5a97..e9b2fb0 100644 --- a/examples/ticket-summarizer/v2.yaml +++ b/examples/ticket-summarizer/v2.yaml @@ -15,7 +15,7 @@ schedule: every: 15m mcp: - - name: desk + - name: support url: http://localhost:8931/mcp tools: - tool: search_tickets diff --git a/tests/e2e/harness.py b/tests/e2e/harness.py index eaae2f9..3c993a0 100644 --- a/tests/e2e/harness.py +++ b/tests/e2e/harness.py @@ -11,7 +11,7 @@ fake accepted unconditionally — so the fakes stop at the model and no further. model = scripted( - calls("desk__get_ticket", ticket_id="4821"), + calls("support__get_ticket", ticket_id="4821"), submits(resolution="refunded", refunded_usd=240), ) diff --git a/tests/e2e/test_failures.py b/tests/e2e/test_failures.py index 6d477ae..a247685 100644 --- a/tests/e2e/test_failures.py +++ b/tests/e2e/test_failures.py @@ -76,7 +76,7 @@ async def test_a_broken_tool_fails_the_task_naming_the_tool(cp, project, tenant) wf = await one_instance(cp, reloaded, "ticket-sweeper", tenant) model = scripted( - calls("desk__always_fails", why="testing"), + calls("support__always_fails", why="testing"), submits(summary="never gets here", needs_attention=0), ) @@ -99,7 +99,7 @@ async def test_a_spent_budget_says_which_ceiling_it_hit(cp, project, tenant): # More turns than the cap allows and no submit, so the cap is the only way out. # (The script no longer repeats its last turn — a repeated tool call gets # replayed by subagents that may not have it, and loops.) - model = scripted(*[calls("desk__list_open_tickets") for _ in range(6)]) + model = scripted(*[calls("support__list_open_tickets") for _ in range(6)]) info = await run_one(cp, reloaded, "ticket-sweeper", wf, model) @@ -119,7 +119,7 @@ async def test_a_failed_task_reports_how_far_it_got(cp, project, tenant): wf = await one_instance(cp, reloaded, "ticket-sweeper", tenant) info = await run_one(cp, reloaded, "ticket-sweeper", wf, - scripted(*[calls("desk__list_open_tickets") for _ in range(6)])) + scripted(*[calls("support__list_open_tickets") for _ in range(6)])) assert set(info.result) >= {"failed", "reason", "cost_usd", "llm_calls", "gates"} @@ -148,7 +148,7 @@ async def test_a_hung_tool_obeys_on_failure_like_any_other(cp, project, tenant): wf = await one_instance(cp, reloaded, "ticket-sweeper", tenant) model = scripted( - calls("desk__hangs", seconds=30), + calls("support__hangs", seconds=30), submits(summary="the tool never answered", needs_attention=0), ) @@ -176,7 +176,7 @@ async def test_a_hung_tool_under_on_failure_fail_names_the_tool(cp, project, ten wf = await one_instance(cp, reloaded, "ticket-sweeper", tenant) model = scripted( - calls("desk__hangs", seconds=30), + calls("support__hangs", seconds=30), submits(summary="never gets here", needs_attention=0), ) diff --git a/tests/e2e/test_lifecycle.py b/tests/e2e/test_lifecycle.py index 2b41431..25f4d38 100644 --- a/tests/e2e/test_lifecycle.py +++ b/tests/e2e/test_lifecycle.py @@ -68,7 +68,7 @@ async def test_a_task_runs_and_publishes_what_the_agent_returned(cp, project, te answer rather than a wrapper to unpick.""" wf = await one_instance(cp, project, "ticket-sweeper", tenant) model = scripted( - calls("desk__list_open_tickets"), + calls("support__list_open_tickets"), submits(summary="two tickets need a look", needs_attention=2), ) @@ -87,7 +87,7 @@ async def test_a_gated_tool_is_offered_and_its_call_is_stopped(cp, project, tena doesn't go through without a human.""" wf = await one_instance(cp, project, "refund-demo", tenant) model = scripted( - calls("desk__create_refund", charge_id="ch_9002", amount_usd=240, + calls("support__create_refund", charge_id="ch_9002", amount_usd=240, reason="duplicate"), submits(resolution="refunded", refunded_usd=240), ) @@ -97,10 +97,10 @@ async def test_a_gated_tool_is_offered_and_its_call_is_stopped(cp, project, tena await cp.invoke_workflow(wf.id, context={"ticket_id": "4821"}) gate = await wait_for_gate(cp, wf.id, timeout=90) - assert "desk__create_refund" in gate.justification + assert "support__create_refund" in gate.justification # The arguments reach the approver, or they're deciding on a name alone. assert "ch_9002" in gate.justification - assert any("desk__create_refund" in names for names in model.offered) + assert any("support__create_refund" in names for names in model.offered) async def test_an_approval_resumes_the_same_conversation(cp, project, tenant): @@ -108,7 +108,7 @@ async def test_an_approval_resumes_the_same_conversation(cp, project, tenant): is the same agent mid-thought rather than a new one starting over.""" wf = await one_instance(cp, project, "refund-demo", tenant) model = scripted( - calls("desk__create_refund", charge_id="ch_9002", amount_usd=240, + calls("support__create_refund", charge_id="ch_9002", amount_usd=240, reason="duplicate"), submits(resolution="refunded the duplicate", refunded_usd=240), ) @@ -129,7 +129,7 @@ async def test_a_rejection_reaches_the_model(cp, project, tenant): the reason only exists at decision time — after the gate was raised.""" wf = await one_instance(cp, project, "refund-demo", tenant) model = scripted( - calls("desk__create_refund", charge_id="ch_7700", amount_usd=89, + calls("support__create_refund", charge_id="ch_7700", amount_usd=89, reason="changed their mind"), submits(resolution="no refund — outside the window", refunded_usd=0), ) diff --git a/tests/e2e/test_live.py b/tests/e2e/test_live.py index df4ad6e..0d4a509 100644 --- a/tests/e2e/test_live.py +++ b/tests/e2e/test_live.py @@ -106,7 +106,7 @@ async def test_a_real_model_proposes_the_gated_tool_rather_than_calling_it( request_id = await cp.invoke_workflow(wf.id, context={"ticket_id": "4821"}) gate = await wait_for_gate(cp, wf.id, timeout=180) - assert "desk__create_refund" in gate.justification + assert "support__create_refund" in gate.justification await cp.approve_workflow(wf.id, gate.approval_id, "live-test", "duplicate") info = await wait_for_run(cp, request_id, timeout=180) @@ -119,4 +119,4 @@ async def test_a_real_model_proposes_the_gated_tool_rather_than_calling_it( # the old mechanism; under the harness the tool is in the list and the *call* # is what stops, so a test that asserted absence would now pass for the wrong # reason. - assert any("desk__create_refund" in names for names in model.offered) + assert any("support__create_refund" in names for names in model.offered) diff --git a/tests/test_agent_config.py b/tests/test_agent_config.py index f1a8edd..e9a2542 100644 --- a/tests/test_agent_config.py +++ b/tests/test_agent_config.py @@ -24,10 +24,10 @@ def test_example_parses(): def test_derived_views(): cfg = AgentConfig.model_validate(load()) - assert cfg.gated_tools == ["desk__create_refund"] - assert "desk__get_ticket" in cfg.inline_tools - assert "desk__create_refund" not in cfg.inline_tools - assert cfg.fail_fast_tools == {"desk__get_ticket", "desk__create_refund"} + assert cfg.gated_tools == ["support__create_refund"] + assert "support__get_ticket" in cfg.inline_tools + assert "support__create_refund" not in cfg.inline_tools + assert cfg.fail_fast_tools == {"support__get_ticket", "support__create_refund"} assert len(cfg.all_tools) == 3 diff --git a/tests/test_cli.py b/tests/test_cli.py index 225103a..f360752 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -235,7 +235,7 @@ def test_pending_renders_the_gate_and_the_command(self, cp): cp.workflows = [workflow( "refund-demo", lifecycle_state=LifecycleState.AWAITING_APPROVAL, pending=PendingApproval(approval_id="apr_1", - justification="run desk__create_refund\n amount: 240", + justification="run support__create_refund\n amount: 240", metadata={}, opened_at=NOW, timeout_at=None))] out = invoke("pending", "refund-demo", "--instance", "wf_refun").output assert "needs approval" in out diff --git a/tests/test_compile.py b/tests/test_compile.py index dc6b7de..69c14df 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -44,8 +44,8 @@ def test_runtime_policy(): assert p.max_tokens_per_call == 1024 assert p.max_call_seconds == 60 assert {l.tool: l.max_calls for l in p.tool_call_limits} == { - "desk__get_charge": 5, - "desk__get_ticket": 10, + "support__get_charge": 5, + "support__get_ticket": 10, } @@ -86,7 +86,7 @@ def test_tool_failures_renames_to_boundflows_misnomer(): a ratio. BoundFlow's metric is named TOOL_FAILURE_RATE.""" rule = next(r for r in refund().workflow_rules if r.metric == WorkflowMetric.TOOL_FAILURE_RATE) - assert rule.tool == "desk__create_refund" + assert rule.tool == "support__create_refund" assert rule.threshold == 3 diff --git a/tests/test_harness_bounds.py b/tests/test_harness_bounds.py index 617f6f6..c9ac7ef 100644 --- a/tests/test_harness_bounds.py +++ b/tests/test_harness_bounds.py @@ -133,7 +133,7 @@ def test_a_declared_subagent_carries_the_same_bounds(): cfg = agent_with([{"name": "researcher", "description": "Reads."}]) gov = governor(allowed_capabilities=["read", "spawn"], allowed_tools=[]) - spec = declared_subagents(cfg, fake_tools("desk__get_ticket"), gov, {})[0] + spec = declared_subagents(cfg, fake_tools("support__get_ticket"), gov, {})[0] assert offer(spec["middleware"], "write_file") == "refused" assert offer(spec["middleware"], "read_file") == "allowed" @@ -143,12 +143,12 @@ def test_a_narrower_tool_list_is_honoured(): from charter.harness.durable import declared_subagents cfg = agent_with([{"name": "researcher", "description": "Reads.", - "tools": ["desk__get_ticket"]}]) - tools = fake_tools("desk__get_ticket", "desk__get_charge") + "tools": ["support__get_ticket"]}]) + tools = fake_tools("support__get_ticket", "support__get_charge") spec = declared_subagents(cfg, tools, governor(), {})[0] - assert [t.name for t in spec["tools"]] == ["desk__get_ticket"] + assert [t.name for t in spec["tools"]] == ["support__get_ticket"] def test_declaring_no_tools_means_the_parents_whole_set(): @@ -171,7 +171,7 @@ def test_a_subagent_cannot_reach_further_than_its_parent(): with _pytest.raises(ValidationError, match="does not declare"): agent_with([{"name": "researcher", "description": "Reads.", - "tools": ["desk__nonexistent"]}]) + "tools": ["support__nonexistent"]}]) def test_general_purpose_cannot_be_redeclared(): diff --git a/tests/test_loader.py b/tests/test_loader.py index 3355bc2..3410fa9 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -65,14 +65,14 @@ def test_runtime_agent_mismatch(self, project): def test_tool_call_limit_for_undeclared_tool(self, project): def mutate(raw): - raw["per_run"]["tool_call_limits"][0]["tool"] = "desk__nonexistent" + raw["per_run"]["tool_call_limits"][0]["tool"] = "support__nonexistent" edit(project / "refund-triage" / "runtime.yaml", mutate) with pytest.raises(ConfigError, match="no version of this agent declares"): load_agent(project / "refund-triage") def test_lifecycle_rule_for_undeclared_tool(self, project): def mutate(raw): - raw["rules"][-1]["when"]["tool"] = "desk__nonexistent" + raw["rules"][-1]["when"]["tool"] = "support__nonexistent" edit(project / "refund-triage" / "lifecycle.yaml", mutate) with pytest.raises(ConfigError, match="no version of this agent declares"): load_agent(project / "refund-triage") diff --git a/tests/test_loop.py b/tests/test_loop.py index a8df633..fc2c707 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -158,8 +158,8 @@ def test_gated_tools_become_interrupts_not_omissions(): rather than reject and hope the next attempt is right.""" cfg = load_agent(EXAMPLES / "refund-triage").latest gates = interrupt_on(cfg) - assert set(gates) == {"desk__create_refund"} - assert gates["desk__create_refund"]["allowed_decisions"] == [ + assert set(gates) == {"support__create_refund"} + assert gates["support__create_refund"]["allowed_decisions"] == [ "approve", "edit", "reject"] @@ -195,16 +195,16 @@ def test_a_pending_action_parks_the_task(): cfg, loop = loop_for() interrupt = {"__interrupt__": [type("I", (), { "value": {"action_requests": [ - {"name": "desk__create_refund", "args": {"amount": 40}, + {"name": "support__create_refund", "args": {"amount": 40}, "description": "Refund $40 to the customer"}]}, "id": "int-1"})()]} ctx = FakeCtx(results=[FakeResult(interrupt)]) out = run(loop.entry(ctx)) assert isinstance(out, AwaitApproval) - assert "desk__create_refund" in out.justification + assert "support__create_refund" in out.justification assert "Refund $40 to the customer" in out.justification - assert out.metadata["tool"] == "desk__create_refund" + assert out.metadata["tool"] == "support__create_refund" assert out.timeout == loop.runtime.authority.approval_timeout_seconds assert ctx.context[K_GATES] == 1 @@ -329,13 +329,13 @@ def test_a_gate_says_what_is_about_to_happen(): _, loop = loop_for() interrupt = {"__interrupt__": [type("I", (), { "value": {"action_requests": [{ - "name": "desk__create_refund", + "name": "support__create_refund", "args": {"charge_id": "ch_9002", "amount_usd": 240}, "description": "Tool execution requires approval"}]}, "id": "i"})()]} out = run(loop.entry(FakeCtx(results=[FakeResult(interrupt)]))) - assert "desk__create_refund" in out.justification + assert "support__create_refund" in out.justification assert "ch_9002" in out.justification assert "Tool execution requires" not in out.justification @@ -415,7 +415,7 @@ def test_a_fail_fast_tool_ends_the_task(): lived in the loop that was deleted. A declared field that quietly does nothing is worse than not having it.""" cfg, loop = loop_for() - assert "desk__create_refund" in cfg.fail_fast_tools or cfg.fail_fast_tools + assert "support__create_refund" in cfg.fail_fast_tools or cfg.fail_fast_tools tool = next(iter(cfg.fail_fast_tools)) out = run(loop.entry(FakeCtx(results=[ @@ -434,7 +434,7 @@ def test_an_ordinary_tool_failure_does_not_end_the_task(): _, loop = loop_for() out = run(loop.entry(FakeCtx(results=[ FakeResult({"resolution": "worked around it"}, - tool_failures={"desk__get_charge": 2})]))) + tool_failures={"support__get_charge": 2})]))) assert isinstance(out, Complete) assert "failed" not in out.result @@ -531,7 +531,7 @@ def _loop(self, **gate): return Loop(bundle.latest, bundle.runtime, tools=empty, chat_model=lambda m: object(), store_url="postgresql://unused") - def _interrupt(self, tool="desk__create_refund"): + def _interrupt(self, tool="support__create_refund"): return {"__interrupt__": [type("I", (), { "value": {"action_requests": [ {"name": tool, "args": {"amount": 40}, "description": "d"}]}, @@ -562,13 +562,13 @@ def test_on_reject_fail_stops_the_task(self): scanning statuses never learns the thing it existed to do didn't happen.""" loop = self._loop(on_reject="fail") ctx = FakeCtx(context={K_DECISION: "reject", - "_gated_tool": "desk__create_refund"}, + "_gated_tool": "support__create_refund"}, approval_reason="too much", results=[FakeResult({"resolution": "unused"})]) out = run(loop.entry(ctx)) assert out.result["failed"] is True - assert "desk__create_refund" in out.result["reason"] + assert "support__create_refund" in out.result["reason"] assert "too much" in out.result["reason"] def test_an_unanswered_gate_under_fail_says_so(self): @@ -608,7 +608,7 @@ def test_declared_mcp_tools_still_gate_themselves(self): """Two mechanisms, one for each declaration site — a tool that declares `approval: always` shouldn't also need naming here.""" cfg = self._cfg([]) - assert "desk__create_refund" in interrupt_on(cfg) + assert "support__create_refund" in interrupt_on(cfg) def test_a_typo_is_refused_rather_than_gating_nothing(self): from charter.config.agent import Gate diff --git a/tests/test_subagents.py b/tests/test_subagents.py index f73cd7d..7a26ba3 100644 --- a/tests/test_subagents.py +++ b/tests/test_subagents.py @@ -57,7 +57,7 @@ async def ok(_req): return "done" async def go(): - return [await mw.awrap_tool_call(Req("desk__get_ticket"), ok) + return [await mw.awrap_tool_call(Req("support__get_ticket"), ok) for _ in range(5)] assert run(go()) == ["done"] * 5 From 39b6cf403e915a6eae3af723ae1fc40632c47325 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Mon, 7 Sep 2026 12:55:30 -0400 Subject: [PATCH 06/49] Leave the playground's tool names alone The rename swept tests/e2e, which loads playground/, not examples/. Its server is still named desk, so those assertions have to keep desk__ names. --- tests/e2e/harness.py | 2 +- tests/e2e/test_failures.py | 10 +++++----- tests/e2e/test_lifecycle.py | 12 ++++++------ tests/e2e/test_live.py | 4 ++-- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/e2e/harness.py b/tests/e2e/harness.py index 3c993a0..eaae2f9 100644 --- a/tests/e2e/harness.py +++ b/tests/e2e/harness.py @@ -11,7 +11,7 @@ fake accepted unconditionally — so the fakes stop at the model and no further. model = scripted( - calls("support__get_ticket", ticket_id="4821"), + calls("desk__get_ticket", ticket_id="4821"), submits(resolution="refunded", refunded_usd=240), ) diff --git a/tests/e2e/test_failures.py b/tests/e2e/test_failures.py index a247685..6d477ae 100644 --- a/tests/e2e/test_failures.py +++ b/tests/e2e/test_failures.py @@ -76,7 +76,7 @@ async def test_a_broken_tool_fails_the_task_naming_the_tool(cp, project, tenant) wf = await one_instance(cp, reloaded, "ticket-sweeper", tenant) model = scripted( - calls("support__always_fails", why="testing"), + calls("desk__always_fails", why="testing"), submits(summary="never gets here", needs_attention=0), ) @@ -99,7 +99,7 @@ async def test_a_spent_budget_says_which_ceiling_it_hit(cp, project, tenant): # More turns than the cap allows and no submit, so the cap is the only way out. # (The script no longer repeats its last turn — a repeated tool call gets # replayed by subagents that may not have it, and loops.) - model = scripted(*[calls("support__list_open_tickets") for _ in range(6)]) + model = scripted(*[calls("desk__list_open_tickets") for _ in range(6)]) info = await run_one(cp, reloaded, "ticket-sweeper", wf, model) @@ -119,7 +119,7 @@ async def test_a_failed_task_reports_how_far_it_got(cp, project, tenant): wf = await one_instance(cp, reloaded, "ticket-sweeper", tenant) info = await run_one(cp, reloaded, "ticket-sweeper", wf, - scripted(*[calls("support__list_open_tickets") for _ in range(6)])) + scripted(*[calls("desk__list_open_tickets") for _ in range(6)])) assert set(info.result) >= {"failed", "reason", "cost_usd", "llm_calls", "gates"} @@ -148,7 +148,7 @@ async def test_a_hung_tool_obeys_on_failure_like_any_other(cp, project, tenant): wf = await one_instance(cp, reloaded, "ticket-sweeper", tenant) model = scripted( - calls("support__hangs", seconds=30), + calls("desk__hangs", seconds=30), submits(summary="the tool never answered", needs_attention=0), ) @@ -176,7 +176,7 @@ async def test_a_hung_tool_under_on_failure_fail_names_the_tool(cp, project, ten wf = await one_instance(cp, reloaded, "ticket-sweeper", tenant) model = scripted( - calls("support__hangs", seconds=30), + calls("desk__hangs", seconds=30), submits(summary="never gets here", needs_attention=0), ) diff --git a/tests/e2e/test_lifecycle.py b/tests/e2e/test_lifecycle.py index 25f4d38..2b41431 100644 --- a/tests/e2e/test_lifecycle.py +++ b/tests/e2e/test_lifecycle.py @@ -68,7 +68,7 @@ async def test_a_task_runs_and_publishes_what_the_agent_returned(cp, project, te answer rather than a wrapper to unpick.""" wf = await one_instance(cp, project, "ticket-sweeper", tenant) model = scripted( - calls("support__list_open_tickets"), + calls("desk__list_open_tickets"), submits(summary="two tickets need a look", needs_attention=2), ) @@ -87,7 +87,7 @@ async def test_a_gated_tool_is_offered_and_its_call_is_stopped(cp, project, tena doesn't go through without a human.""" wf = await one_instance(cp, project, "refund-demo", tenant) model = scripted( - calls("support__create_refund", charge_id="ch_9002", amount_usd=240, + calls("desk__create_refund", charge_id="ch_9002", amount_usd=240, reason="duplicate"), submits(resolution="refunded", refunded_usd=240), ) @@ -97,10 +97,10 @@ async def test_a_gated_tool_is_offered_and_its_call_is_stopped(cp, project, tena await cp.invoke_workflow(wf.id, context={"ticket_id": "4821"}) gate = await wait_for_gate(cp, wf.id, timeout=90) - assert "support__create_refund" in gate.justification + assert "desk__create_refund" in gate.justification # The arguments reach the approver, or they're deciding on a name alone. assert "ch_9002" in gate.justification - assert any("support__create_refund" in names for names in model.offered) + assert any("desk__create_refund" in names for names in model.offered) async def test_an_approval_resumes_the_same_conversation(cp, project, tenant): @@ -108,7 +108,7 @@ async def test_an_approval_resumes_the_same_conversation(cp, project, tenant): is the same agent mid-thought rather than a new one starting over.""" wf = await one_instance(cp, project, "refund-demo", tenant) model = scripted( - calls("support__create_refund", charge_id="ch_9002", amount_usd=240, + calls("desk__create_refund", charge_id="ch_9002", amount_usd=240, reason="duplicate"), submits(resolution="refunded the duplicate", refunded_usd=240), ) @@ -129,7 +129,7 @@ async def test_a_rejection_reaches_the_model(cp, project, tenant): the reason only exists at decision time — after the gate was raised.""" wf = await one_instance(cp, project, "refund-demo", tenant) model = scripted( - calls("support__create_refund", charge_id="ch_7700", amount_usd=89, + calls("desk__create_refund", charge_id="ch_7700", amount_usd=89, reason="changed their mind"), submits(resolution="no refund — outside the window", refunded_usd=0), ) diff --git a/tests/e2e/test_live.py b/tests/e2e/test_live.py index 0d4a509..df4ad6e 100644 --- a/tests/e2e/test_live.py +++ b/tests/e2e/test_live.py @@ -106,7 +106,7 @@ async def test_a_real_model_proposes_the_gated_tool_rather_than_calling_it( request_id = await cp.invoke_workflow(wf.id, context={"ticket_id": "4821"}) gate = await wait_for_gate(cp, wf.id, timeout=180) - assert "support__create_refund" in gate.justification + assert "desk__create_refund" in gate.justification await cp.approve_workflow(wf.id, gate.approval_id, "live-test", "duplicate") info = await wait_for_run(cp, request_id, timeout=180) @@ -119,4 +119,4 @@ async def test_a_real_model_proposes_the_gated_tool_rather_than_calling_it( # the old mechanism; under the harness the tool is in the list and the *call* # is what stops, so a test that asserted absence would now pass for the wrong # reason. - assert any("support__create_refund" in names for names in model.offered) + assert any("desk__create_refund" in names for names in model.offered) From 44e54f727f8c461e415b6d9d237ddc0f20c4ca5b Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 00:56:26 -0400 Subject: [PATCH 07/49] Stop offering deleted instances to pick from `charter agents` filters instances whose lifecycle_state is deleted; the picker behind --instance did not, so the two disagreed about what exists and the picker suggested a deleted one as the default. Its docstring already said 'every live instance'. --- charter/cli.py | 3 ++- tests/test_cli.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/charter/cli.py b/charter/cli.py index da81d4e..dacfc37 100644 --- a/charter/cli.py +++ b/charter/cli.py @@ -638,7 +638,8 @@ async def _instances(cp, agent: str, tenant: str | None = None) -> list: """ tid = await _tenant_id(cp, tenant) return [w for w in await cp.list_workflows() - if w.workflow_type == agent and w.tenant_id == tid] + if w.workflow_type == agent and w.tenant_id == tid + and w.lifecycle_state.value != "deleted"] async def _select(cp, agent: str, *, instance: str | None, all_: bool, diff --git a/tests/test_cli.py b/tests/test_cli.py index f360752..ac980d8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -751,3 +751,17 @@ def test_describe_suggests_commands_that_parse(self, cp): args = [a.strip("'") for a in cmd.split()[1:]] res = invoke(*args) assert res.exit_code == 0, f"{cmd!r} exits {res.exit_code}: {res.output}" + + +def test_a_deleted_instance_is_not_offered_to_pick_from(cp): + """`charter agents` hides deleted instances and the picker did not, so the two + disagreed about what exists and the picker suggested a dead one by default. + """ + cp.workflows = [workflow("refund-triage", + lifecycle_state=LifecycleState.DELETED), + workflow("refund-triage")] + + out = invoke("describe", "refund-triage").output + + assert "has 1 instance" in out, out + assert "deleted" not in out From 27dc3342dd0f1f91fc5c8fc828a511aa36db4ba1 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 13:12:43 -0400 Subject: [PATCH 08/49] Run a task without a checkout `charter run` loaded the agent's YAML to validate flags, so triggering a task needed the repo. Nothing else does: Prefect validates against a schema the server holds, Temporal sends an opaque payload. Inputs now go as given. The worker already fills declared defaults and refuses a task missing a required input, from the versioned config it is serving, so the CLI was keeping a second copy of a spec it could not guarantee was current. Flag values are text, so numbers and booleans are read off the value, the same guess YAML makes. --path is gone from run, and _coerce and _show_inputs with it. --- charter/cli.py | 101 ++++++++++++++----------------------------------- 1 file changed, 29 insertions(+), 72 deletions(-) diff --git a/charter/cli.py b/charter/cli.py index dacfc37..31ab747 100644 --- a/charter/cli.py +++ b/charter/cli.py @@ -711,71 +711,32 @@ async def _workflow_for(cp, agent: str, tenant: str | None = None, tenant=tenant, verb=verb, fans_out=False))[0] -def _show_inputs(cfg) -> None: - """What this agent takes. Printed on the error paths rather than in --help, - because Typer builds --help before we know which agent was named — and the - moment someone needs this is the moment they got a flag wrong.""" - if not cfg.inputs: - typer.echo(" (this agent declares no inputs)") - return - typer.echo(f"\ninputs for {cfg.name}:") - for name, spec in cfg.inputs.items(): - flag = f"--{name.replace('_', '-')}" - bits = [spec.type] - if spec.required: - bits.append("required") - if spec.default is not None: - bits.append(f"default {spec.default}") - if spec.enum: - bits.append("one of " + "|".join(str(v) for v in spec.enum)) - typer.echo(f" {flag:<24} {', '.join(bits)}") - if spec.description: - typer.echo(f" {'':<24} {spec.description}") - - -def _coerce(spec, raw: str, name: str): - """CLI flags arrive as strings; the declared type is what they must become.""" - try: - if spec.type == "integer": - return int(raw) - if spec.type == "number": - return float(raw) - if spec.type == "boolean": - return raw.lower() in ("1", "true", "yes", "y") - return raw - except ValueError: - raise typer.BadParameter(f"--{name.replace('_', '-')} must be a {spec.type}") + + + + @app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) def run( ctx: typer.Context, - agent: str = typer.Argument(..., help="Agent name (its directory)"), - path: Path = typer.Option(Path("."), "--path", help="Where agents live"), + agent: str = typer.Argument(..., help="Agent name"), instance: str = typer.Option(None, "--instance", help="Which instance to run on"), all_: bool = typer.Option(False, "--all", help="Start a task on every instance"), tenant: str = TENANT, ) -> None: - """Start one task. Declared inputs become --flags, validated before the request - is created so a typo fails here instead of burning a run. + """Start one task. Declared inputs are passed as --flags. + + No checkout needed: the inputs travel with the versioned config the worker is + already serving, so it is the worker that fills declared defaults and refuses a + task missing a required one. Validating here as well would mean a second copy + of the spec, and the CLI disagreeing with the worker whenever it was stale. An agent with several instances needs one naming: each has its own state, so sending work to the wrong one isn't a scheduling detail, it's the wrong entity doing the job. """ - agent_dir = Path(path) / agent - if not agent_dir.is_dir(): - agent_dir = Path(path) - try: - bundle = load_agent(agent_dir) - except ConfigError as e: - _err(f"no agent config found for {agent!r} — `run` needs it to validate inputs") - for p in e.problems: - _err(f" - {p}") - raise typer.Exit(1) - - cfg = bundle.latest - flags = {} + context = {} args = list(ctx.args) while args: token = args.pop(0) @@ -788,27 +749,7 @@ def run( value = args.pop(0) else: value = "true" - flags[key] = value - - unknown = set(flags) - set(cfg.inputs) - if unknown: - _err(f"unknown input(s): {', '.join(sorted(unknown))}") - _show_inputs(cfg) - raise typer.Exit(1) - - context = {} - for name, spec in cfg.inputs.items(): - if name in flags: - context[name] = _coerce(spec, flags[name], name) - elif spec.default is not None: - context[name] = spec.default - elif spec.required: - _err(f"--{name.replace('_', '-')} is required") - _show_inputs(cfg) - raise typer.Exit(1) - if spec.enum and name in context and context[name] not in spec.enum: - _err(f"--{name.replace('_', '-')} must be one of {spec.enum}") - raise typer.Exit(1) + context[key] = _typed(value) async def go(): async with _cp() as cp: @@ -824,6 +765,22 @@ async def go(): asyncio.run(go()) +def _typed(value: str): + """A flag is text; the config it feeds declares numbers and booleans. + + Nothing here knows the declared type, so the shape is read off the value. A + quoted number stays a number, which is the same guess YAML makes. + """ + if value in ("true", "false"): + return value == "true" + for cast in (int, float): + try: + return cast(value) + except ValueError: + pass + return value + + agent_app = typer.Typer(help="Create and destroy instances of an agent.") app.add_typer(agent_app, name="agent") From a03281a6b19ac15551101377f070b95a495fb572 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 13:39:14 -0400 Subject: [PATCH 09/49] Let the approver's reason change the refund Rejecting produced a failed task: the objective said nothing about being refused, so the agent reopened the question, ended its turn on prose, and never called submit_result. A task with no answer in the shape response_format declares is a failed task. It now revises from the reason it was given. Rejecting $48.00 with 'only half is ours, refund 24.00' brings back a $24.00 proposal and a second gate, and approving that finishes the task with both charges accounted for. Attempts are capped in runtime.yaml rather than the objective, at three calls to support__create_refund. The prompt asks it to revise rather than repeat; the ceiling is what holds when it doesn't. --- charter/subagents.py | 115 ++++++++++++++++++++++++++++ examples/README.md | 37 +++++++-- examples/refund-triage/runtime.yaml | 3 + examples/refund-triage/v1.yaml | 5 ++ tests/test_compile.py | 1 + 5 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 charter/subagents.py diff --git a/charter/subagents.py b/charter/subagents.py new file mode 100644 index 0000000..6810dbe --- /dev/null +++ b/charter/subagents.py @@ -0,0 +1,115 @@ +"""Bound how many subagents an agent may run. + +The harness gives you two general knobs and neither is about subagents. +`ToolCallLimitMiddleware` counts one tool cumulatively, which gets you a total but +nothing about how many are in flight. `max_concurrency` is a semaphore in the +graph executor, which bounds *every* parallel task — set it to hold subagents down +and you also serialise ordinary tool calls, which are cheap and where the +parallelism is a win. + +Neither knows what a subagent is, because to the harness `task` is just a tool. So +this is ours: it wraps that one tool and nothing else. + +Two numbers, stopping different things: + + max_total_subagents a budget. An agent looping on subagents runs out of + money eventually, but stopping at a stated ceiling says + why, and says it before the money is gone. + max_parallel_subagents a valve. Fifty spawned in one turn are all in flight + before any of them has recorded a cost, so no spend cap + can catch that burst in time. + +Over either limit the call is refused rather than raised — the model is told and +carries on with fewer helpers, which is how a spent cap behaves everywhere else. +""" + +from __future__ import annotations + +import logging + +log = logging.getLogger(__name__) + +# deepagents' name for the tool an agent calls to start a subagent. +TASK_TOOL = "task" + + +def subagent_limits(per_run) -> list: + """Middleware enforcing the subagent bounds, or nothing if none are set.""" + if not (per_run.max_total_subagents or per_run.max_parallel_subagents): + return [] + return [_subagent_middleware(per_run.max_total_subagents, + per_run.max_parallel_subagents)] + + +def _subagent_middleware(total: int, parallel: int): + from langchain.agents.middleware import AgentMiddleware + + class SubagentLimits(AgentMiddleware): + """Counts spawns, and how many are running right now. + + State lives on the instance rather than in graph state because it only has + to survive one operation: a task that parks and resumes rebuilds the graph + anyway, and a fresh allowance per round is the more forgiving reading of a + limit that exists to stop a burst. + """ + + def __init__(self) -> None: + super().__init__() + self.spawned = 0 + self.running = 0 + + def _refusal(self, request): + if total and self.spawned >= total: + return (f"Subagent limit reached ({total} for this task). Do the " + f"remaining work yourself, or report what you have.") + if parallel and self.running >= parallel: + return (f"Too many subagents running at once (limit {parallel}). " + f"Wait for one to finish before starting another.") + return None + + def _tool(self, request) -> str: + call = getattr(request, "tool_call", None) or {} + return call.get("name", "") + + async def awrap_tool_call(self, request, handler): + if self._tool(request) != TASK_TOOL: + return await handler(request) + + if (why := self._refusal(request)) is not None: + log.info("subagent refused: %s", why) + return _refuse(request, why) + + self.spawned += 1 + self.running += 1 + try: + return await handler(request) + finally: + # In `finally` so a subagent that raised still frees its slot — + # otherwise a few failures would wedge the gauge at the limit and + # the agent could never spawn again. + self.running -= 1 + + def wrap_tool_call(self, request, handler): + # The sync path exists for completeness; deepagents drives `task` + # through the async one. + if self._tool(request) != TASK_TOOL: + return handler(request) + if (why := self._refusal(request)) is not None: + return _refuse(request, why) + self.spawned += 1 + self.running += 1 + try: + return handler(request) + finally: + self.running -= 1 + + return SubagentLimits() + + +def _refuse(request, message: str): + """A refusal the model can read, shaped like any other tool result.""" + from langchain_core.messages import ToolMessage + + call = getattr(request, "tool_call", None) or {} + return ToolMessage(content=message, tool_call_id=call.get("id", ""), + name=call.get("name", TASK_TOOL), status="error") diff --git a/examples/README.md b/examples/README.md index 2cf47b1..90b5768 100644 --- a/examples/README.md +++ b/examples/README.md @@ -34,14 +34,39 @@ holds your terminal open while it waits: charter pending refund-triage --instance -That prints the call it wants to make and the commands that answer it. Approve, -and the refund goes through and the task finishes. Reject, and the agent is told -and carries on without it. +That prints the call it wants to make and the two commands that answer it. Both +take a reason, and the reason is not paperwork: it is handed to the agent. -`charter ui` shows the same thing in a browser, across every agent at once. + charter approve --agent refund-triage --instance --reason '...' + charter reject --agent refund-triage --instance --reason '...' -There are four tickets, T-1041 to T-1044. One is a duplicate charge, one is a -size exchange the refund policy says not to refund. +Approve, and the refund goes through and the task finishes with what it did. + +Reject with a reason that says what was wrong, and the agent works from it: + + charter reject --agent refund-triage --instance \ + --reason 'only half of this is ours. the second charge was authorised by the + customer on a different order, so refund 24.00, not 48.00' + +It comes back with a corrected proposal, and a second gate: + + refund-triage wants to call support__create_refund + with charge_id='ch_88213', amount_usd=24.0 + +Approve that one and the task finishes: + + result + refunded_usd 24.0 + resolution Customer was charged twice for order #4417 on the 3rd at + $48.00 each. Refunded $24.00 for our duplicate charge; the + other $48.00 charge was authorized by the customer and remains. + +The agent will keep revising while you keep giving it reasons, so `runtime.yaml` +caps `support__create_refund` at three calls per task. The objective asks it to +revise rather than repeat; the ceiling is what holds when it doesn't. + +`charter status ` is where you read the outcome, and `charter ui` does +all of this in a browser, across every agent at once. ## What each file is for diff --git a/examples/refund-triage/runtime.yaml b/examples/refund-triage/runtime.yaml index e56ef60..515b936 100644 --- a/examples/refund-triage/runtime.yaml +++ b/examples/refund-triage/runtime.yaml @@ -8,6 +8,9 @@ per_run: max_cost_usd: 0.30 max_llm_calls: 40 tool_call_limits: + # Let the agent get 3 chances to get the refund amount right, we will give it feedback during the gate + - tool: support__create_refund + max_calls: 3 - tool: support__get_charge max_calls: 5 - tool: support__get_ticket diff --git a/examples/refund-triage/v1.yaml b/examples/refund-triage/v1.yaml index 950814e..93ce592 100644 --- a/examples/refund-triage/v1.yaml +++ b/examples/refund-triage/v1.yaml @@ -17,6 +17,11 @@ objective: | Refunds stop for a human before they go through, so say plainly what you are about to do and why: that sentence is what the approver reads. + If a refund is refused, the approver's reason tells you what was wrong with it. + Use it: propose a corrected refund if the reason points at one. Never re-propose + an amount that was just refused. If the reason means no refund is right, say so + and stop. + inputs: ticket_id: type: string diff --git a/tests/test_compile.py b/tests/test_compile.py index 69c14df..37ef4eb 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -44,6 +44,7 @@ def test_runtime_policy(): assert p.max_tokens_per_call == 1024 assert p.max_call_seconds == 60 assert {l.tool: l.max_calls for l in p.tool_call_limits} == { + "support__create_refund": 3, "support__get_charge": 5, "support__get_ticket": 10, } From f1ffa9c1ec150bd80cbf6b10fa86f25a2cedd6ee Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 13:41:13 -0400 Subject: [PATCH 10/49] Say what the example config is, not why Comments in the example files explained reasoning and history. They now state the field. Dropped an orphaned note in v1.yaml describing approval_timeout_seconds, which lives in runtime.yaml, and the em dashes. --- examples/refund-triage/runtime.yaml | 10 +++------- examples/refund-triage/v1.yaml | 5 +---- examples/worker.yaml | 4 ++-- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/examples/refund-triage/runtime.yaml b/examples/refund-triage/runtime.yaml index 515b936..1489a73 100644 --- a/examples/refund-triage/runtime.yaml +++ b/examples/refund-triage/runtime.yaml @@ -15,8 +15,7 @@ per_run: max_calls: 5 - tool: support__get_ticket max_calls: 10 - # Covers every way the harness can read a file, so the cap survives it shipping - # a fourth one. Capping `read_file` alone would not. + # Every way the harness can read, not just read_file. capability_call_limits: - capability: read max_calls: 30 @@ -25,10 +24,7 @@ limits: max_tokens_per_call: 1024 max_call_seconds: 60 -# What the agent may reach, as opposed to how much it may spend. Not versioned: -# tightening this should not require cutting a release, and it has to be -# changeable on an agent that is already running. +# What the agent may reach. Re-applied on every `charter apply`. authority: - # The harness brings its own filesystem. This agent has no business writing to - # it, so it gets read only; declared MCP tools are always permitted regardless. + # Read only, on the harness's own filesystem. Declared MCP tools are unaffected. allowed_capabilities: [read] diff --git a/examples/refund-triage/v1.yaml b/examples/refund-triage/v1.yaml index 93ce592..a850b4f 100644 --- a/examples/refund-triage/v1.yaml +++ b/examples/refund-triage/v1.yaml @@ -12,7 +12,7 @@ objective: | Look up the ticket and the underlying charge before you act. Never refund more than ${{ inputs.max_refund_usd }}. If the customer's reason is unclear, or the - charge doesn't match what they describe, ask — do not guess. + charge doesn't match what they describe, ask rather than guess. Refunds stop for a human before they go through, so say plainly what you are about to do and why: that sentence is what the approver reads. @@ -54,6 +54,3 @@ response_format: refunded_usd: type: number description: Amount refunded, or 0 if none. - -# How long an approver has. An unanswered gate is a rejection — the agent is told -# and carries on without that action, rather than the task dying. diff --git a/examples/worker.yaml b/examples/worker.yaml index 396dc6d..96d30d9 100644 --- a/examples/worker.yaml +++ b/examples/worker.yaml @@ -5,7 +5,7 @@ name: worker-primary control_plane: endpoint: ${BOUNDFLOW_SERVER_ADDRESS} - # Where workers claim tasks — a different address from the control API. + # Where workers claim tasks. Not the control API address. worker_endpoint: ${BOUNDFLOW_WORKER_ADDRESS} api_key: ${BOUNDFLOW_API_KEY} tenant: default @@ -15,7 +15,7 @@ llm: api_key: ${ANTHROPIC_API_KEY} # The agent's conversation and files between rounds. Yours, not the control -# plane's — what lands here is prompts and whatever the agent touched. +# plane's. store: url: ${CHARTER_STORE_URL} From 9bbbf69516bbb4404742e7fc719620260d7fac0a Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 14:20:25 -0400 Subject: [PATCH 11/49] Show rules as fields, not sentences `describe` printed each rule as '6 of 4 -> pause window=10'. The count is the current version's total and the threshold is scoped to a window of recent tasks, so it read as progress toward something it was not measuring. Rules are now a table of metric, threshold, window, action and tool. Version totals move to their own block with the version as a field. The arrow is gone from apply, describe, runs and audit, which now print metric=, threshold= and action= like the rest of the output. --- charter/cli.py | 57 +++++++++++++++------------ examples/refund-triage/lifecycle.yaml | 15 +++---- examples/refund-triage/runtime.yaml | 2 +- tests/test_compile.py | 31 +++++++++++++-- tests/test_loader.py | 9 ++++- 5 files changed, 74 insertions(+), 40 deletions(-) diff --git a/charter/cli.py b/charter/cli.py index 31ab747..8d75e02 100644 --- a/charter/cli.py +++ b/charter/cli.py @@ -470,8 +470,8 @@ def _print_compiled(c) -> None: action = rule.action.model_dump() kind = action.pop("kind", "?") detail = " ".join(f"{k}={v}" for k, v in action.items()) - typer.echo(f" rule {rule.metric.value} >= {rule.threshold:g}" - f" -> {kind} {detail}".rstrip()) + typer.echo(f" rule metric={rule.metric.value} " + f"threshold={rule.threshold:g} action={kind} {detail}".rstrip()) def _apply_single(bundle, *, dry_run: bool) -> None: @@ -691,6 +691,19 @@ async def _select(cp, agent: str, *, instance: str | None, all_: bool, raise typer.Exit(1) +def _rule_row(rule) -> list[str]: + """One lifecycle rule as metric, threshold, window, action, tool.""" + action = rule.action.model_dump() + kind = action.pop("kind", "?") + window = action.pop("window", "") + seconds = action.pop("seconds", None) + detail = " ".join(f"{k}={v}" for k, v in action.items()) + if seconds: + detail = f"{seconds:g}s {detail}".strip() + return [rule.metric.value, f"{rule.threshold:g}", str(window), + f"{kind} {detail}".strip(), rule.tool or ""] + + def _state_of(w) -> str: state = getattr(w, "workflow_state", None) return getattr(state, "value", state) or "unknown" @@ -1044,34 +1057,25 @@ async def go(): rules = await cp.get_workflow_lifecycle_policy(wf.id) metrics = await cp.get_workflow_metrics(wf.id) - observed = { - "num_failures": metrics.total_failures, - "cost": round(metrics.total_cost_usd, 4), - "num_llm_calls": metrics.total_llm_calls, - "latency": round(metrics.total_latency_seconds, 1), - "approval_rejections": metrics.total_approval_rejections, - } typer.echo() typer.secho("rules", fg=typer.colors.BRIGHT_BLACK) if not rules: ui.detail("none armed") - labels = [f"{r.metric.value}{f'[{r.tool}]' if r.tool else ''}" for r in rules] - width = max((len(l) for l in labels), default=0) - for rule, label in zip(rules, labels): - action = rule.action.model_dump() - kind = action.pop("kind", "?") - detail = " ".join(f"{k}={v}" for k, v in action.items()) - now = (metrics.tool_failure_counts.get(rule.tool, 0) if rule.tool - else observed.get(rule.metric.value, 0)) - line = (f" {label.ljust(width)} {now} of {rule.threshold:g}" - f" -> {kind} {detail}".rstrip()) - (ui.warn if now >= rule.threshold else typer.echo)(line) + else: + ui.table(["metric", "threshold", "window", "action", "tool"], + [_rule_row(r) for r in rules]) typer.echo() - typer.secho("so far", fg=typer.colors.BRIGHT_BLACK) - ui.kv([("runs", metrics.run_count), + typer.secho("metrics", fg=typer.colors.BRIGHT_BLACK) + # Totals for the version now running, which is not the window a pause or + # cooldown rule reads. Printed as its own block rather than beside a + # threshold, where it read as progress toward one. + ui.kv([("version", f"v{wf.version}"), + ("runs", metrics.run_count), ("cost", f"${metrics.total_cost_usd:.4f}"), - ("llm calls", metrics.total_llm_calls)], indent=" ") + ("llm calls", metrics.total_llm_calls), + ("failures", metrics.total_failures), + ("rejections", metrics.total_approval_rejections)], indent=" ") if wf.pending_approval: g = wf.pending_approval @@ -1327,7 +1331,7 @@ def _rule_line(metric: str, value, rules: list, tool: str | None = None) -> None if getattr(rule.then, k)) at = rule.when.threshold near = value >= at - text = f" {label:<28} {value} (of {at:g} -> {action})" + text = f" {label:<28} {value} threshold={at:g} action={action}" (_warn if near else typer.echo)(text) @@ -1643,8 +1647,9 @@ async def go(): typer.echo(f"{stamp} input {e.decision.value}: " f"{(e.answer or {}).get('text', '')}") else: - typer.echo(f"{stamp} policy fired: {getattr(e, 'metric', '')} -> " - f"{getattr(e, 'action', '')}") + typer.echo(f"{stamp} policy fired: " + f"metric={getattr(e, 'metric', '')} " + f"action={getattr(e, 'action', '')}") asyncio.run(go()) diff --git a/examples/refund-triage/lifecycle.yaml b/examples/refund-triage/lifecycle.yaml index 6207685..9c9f9a1 100644 --- a/examples/refund-triage/lifecycle.yaml +++ b/examples/refund-triage/lifecycle.yaml @@ -4,14 +4,15 @@ kind: LifecyclePolicy agent: refund-triage rules: + # If 4 refunds are turned down in the last ten tasks, stay paused until someone + # runs `charter resume`. + - when: { metric: approval_rejections, threshold: 4 } + then: { pause: { window: 10 } } + + # If 2 tasks failed in the last 5 runs, pause the agent - when: { metric: num_failures, threshold: 2 } - then: { pause: { window: 5 } } + then: { pause: { window: 5 } } + # 5 dollar budget in 20 task runs. If agent crosses it, stop for five minutes, then continue. - when: { metric: cost, threshold: 5.00 } then: { cooldown: { window: 20, seconds: 300 } } - - - when: { metric: approval_rejections, threshold: 3 } - then: { set_version: { target: 1 } } - - - when: { metric: tool_failures, threshold: 3, tool: support__create_refund } - then: { pause: { window: 10 } } diff --git a/examples/refund-triage/runtime.yaml b/examples/refund-triage/runtime.yaml index 1489a73..d6ffd28 100644 --- a/examples/refund-triage/runtime.yaml +++ b/examples/refund-triage/runtime.yaml @@ -8,7 +8,7 @@ per_run: max_cost_usd: 0.30 max_llm_calls: 40 tool_call_limits: - # Let the agent get 3 chances to get the refund amount right, we will give it feedback during the gate + # Let the agent get 3 chances to get the refund amount right, we will give it feedback if we reject - tool: support__create_refund max_calls: 3 - tool: support__get_charge diff --git a/tests/test_compile.py b/tests/test_compile.py index 37ef4eb..ca08188 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -2,7 +2,8 @@ from boundflow import Cooldown, InvokeMode, Pause, SetVersion, WorkflowMetric -from charter.compile import compile_agent +from charter.compile import compile_agent, compile_workflow_rules +from charter.config.lifecycle import LifecyclePolicyFile from charter.config.loader import load_agent EXAMPLES = Path(__file__).parent.parent / "examples" @@ -12,6 +13,18 @@ def refund(): return compile_agent(load_agent(EXAMPLES / "refund-triage")) +def compiled_rules(*rules): + """Rules built here rather than read from an example. + + These assert how the compiler translates each action and metric. Reading them + off `examples/` tied that coverage to what the example happens to demonstrate, + so trimming a rule there silently deleted a compiler test. + """ + return compile_workflow_rules(LifecyclePolicyFile.model_validate({ + "apiVersion": "charter/v1", "kind": "LifecyclePolicy", + "agent": "refund-triage", "rules": list(rules)})) + + def summarizer(version=None): return compile_agent(load_agent(EXAMPLES / "ticket-summarizer"), version) @@ -66,7 +79,14 @@ def test_convergence_limits_have_no_boundflow_equivalent(): def test_workflow_rules(): - rules = {r.metric: r for r in refund().workflow_rules} + rules = {r.metric: r for r in compiled_rules( + {"when": {"metric": "num_failures", "threshold": 2}, + "then": {"pause": {"window": 5}}}, + {"when": {"metric": "cost", "threshold": 5.0}, + "then": {"cooldown": {"window": 20, "seconds": 300}}}, + {"when": {"metric": "approval_rejections", "threshold": 3}, + "then": {"set_version": {"target": 1}}}, + )} failures = rules[WorkflowMetric.NUM_FAILURES] assert failures.threshold == 2 @@ -85,8 +105,11 @@ def test_workflow_rules(): def test_tool_failures_renames_to_boundflows_misnomer(): """Charter says `tool_failures` because the engine compares a summed count, not a ratio. BoundFlow's metric is named TOOL_FAILURE_RATE.""" - rule = next(r for r in refund().workflow_rules - if r.metric == WorkflowMetric.TOOL_FAILURE_RATE) + rule, = compiled_rules( + {"when": {"metric": "tool_failures", "threshold": 3, + "tool": "support__create_refund"}, + "then": {"pause": {"window": 10}}}) + assert rule.metric == WorkflowMetric.TOOL_FAILURE_RATE assert rule.tool == "support__create_refund" assert rule.threshold == 3 diff --git a/tests/test_loader.py b/tests/test_loader.py index 3410fa9..7c60324 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -72,14 +72,19 @@ def mutate(raw): def test_lifecycle_rule_for_undeclared_tool(self, project): def mutate(raw): - raw["rules"][-1]["when"]["tool"] = "support__nonexistent" + raw["rules"].append( + {"when": {"metric": "tool_failures", "threshold": 3, + "tool": "support__nonexistent"}, + "then": {"pause": {"window": 5}}}) edit(project / "refund-triage" / "lifecycle.yaml", mutate) with pytest.raises(ConfigError, match="no version of this agent declares"): load_agent(project / "refund-triage") def test_set_version_target_missing_on_disk(self, project): def mutate(raw): - raw["rules"][2]["then"]["set_version"]["target"] = 7 + raw["rules"].append( + {"when": {"metric": "cost", "threshold": 9.0}, + "then": {"set_version": {"target": 7}}}) edit(project / "refund-triage" / "lifecycle.yaml", mutate) with pytest.raises(ConfigError, match="no v7.yaml"): load_agent(project / "refund-triage") From 9c78f3493fa8eabbba684bd3c8913e185d138b5d Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 17:26:01 -0400 Subject: [PATCH 12/49] Size the example's windows so its rules can fire `window` is a lookback over recent runs and a minimum sample size: a rule does not evaluate until that many runs exist. At window 10 none of these rules could fire until someone had run the agent ten times, so nothing in the example was demonstrable. Rejections and failures now look back 3 runs, spend 5. The comment says what window means, because the behaviour is not obvious from the field name. --- examples/refund-triage/lifecycle.yaml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/examples/refund-triage/lifecycle.yaml b/examples/refund-triage/lifecycle.yaml index 9c9f9a1..7d08912 100644 --- a/examples/refund-triage/lifecycle.yaml +++ b/examples/refund-triage/lifecycle.yaml @@ -3,16 +3,20 @@ kind: LifecyclePolicy agent: refund-triage +# `window` is a lookback over recent runs and a minimum sample size: a rule does +# not evaluate until that many runs exist. Small windows here so the rules can +# fire in a demo. rules: - # If 4 refunds are turned down in the last ten tasks, stay paused until someone + # If 2 refunds are turned down in the last 3 runs, stay paused until someone # runs `charter resume`. - - when: { metric: approval_rejections, threshold: 4 } - then: { pause: { window: 10 } } + - when: { metric: approval_rejections, threshold: 2 } + then: { pause: { window: 3 } } - # If 2 tasks failed in the last 5 runs, pause the agent + # If 2 tasks failed in the last 3 runs, pause the agent. - when: { metric: num_failures, threshold: 2 } - then: { pause: { window: 5 } } + then: { pause: { window: 3 } } - # 5 dollar budget in 20 task runs. If agent crosses it, stop for five minutes, then continue. + # 5 dollar budget across 5 runs. If the agent crosses it, stop for five + # minutes, then continue. - when: { metric: cost, threshold: 5.00 } - then: { cooldown: { window: 20, seconds: 300 } } + then: { cooldown: { window: 5, seconds: 300 } } From ce97fdcffa6d56412fe08f229af9b9f920e38f97 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 17:32:25 -0400 Subject: [PATCH 13/49] Make the rejection rule span runs One run can propose at most 3 refunds, so a threshold of 4 cannot be reached inside a single run. The rule now demonstrates what a lifecycle policy is for: a pattern across runs, not a limit within one. --- examples/refund-triage/lifecycle.yaml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/examples/refund-triage/lifecycle.yaml b/examples/refund-triage/lifecycle.yaml index 7d08912..a629af7 100644 --- a/examples/refund-triage/lifecycle.yaml +++ b/examples/refund-triage/lifecycle.yaml @@ -3,13 +3,10 @@ kind: LifecyclePolicy agent: refund-triage -# `window` is a lookback over recent runs and a minimum sample size: a rule does -# not evaluate until that many runs exist. Small windows here so the rules can -# fire in a demo. rules: - # If 2 refunds are turned down in the last 3 runs, stay paused until someone + # If 4 approvals are rejected in the last 3 runs, stay paused until someone # runs `charter resume`. - - when: { metric: approval_rejections, threshold: 2 } + - when: { metric: approval_rejections, threshold: 4 } then: { pause: { window: 3 } } # If 2 tasks failed in the last 3 runs, pause the agent. From 4ed5285c881fb2b39118a0fc9469a193b58050e0 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 18:15:34 -0400 Subject: [PATCH 14/49] Show both lifecycle actions, one per example The rules existed in the config and nothing in the README made one fire, so a reader saw policy and took it on faith. ticket-summarizer is the rollback: v2 is a pricier model on a schedule, v1 is neither, and crossing the spend threshold puts v1 back. refund-triage is the pause: four rejections across three runs and it stops itself, which one run cannot reach because the tool cap is three. The audit trail printed WorkflowPolicyAction.SET_VERSION, an SDK enum repr. It now reads set_version. --- README.md | 5 ++- charter/cli.py | 13 ++++++- examples/README.md | 47 ++++++++++++++++++++++- examples/refund-triage/lifecycle.yaml | 13 +------ examples/ticket-summarizer/lifecycle.yaml | 8 ++-- 5 files changed, 66 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index b8c3232..1266432 100644 --- a/README.md +++ b/README.md @@ -288,8 +288,9 @@ through their workers and the control plane whether or not the CLI is installed. - [DESIGN.md](DESIGN.md): every field of every file, and the decisions behind them - [deploy/](deploy/): running workers as containers, and a control plane locally -- [examples/](examples/): two agents over a toy support system, one of them gating - a refund. They run with nothing but a model key +- [examples/](examples/): two agents over a toy support system. One gates a refund + and pauses itself when too many are turned down, the other rolls itself back to + an earlier version. They run with nothing but a model key ## Development diff --git a/charter/cli.py b/charter/cli.py index 8d75e02..39d2918 100644 --- a/charter/cli.py +++ b/charter/cli.py @@ -704,6 +704,15 @@ def _rule_row(rule) -> list[str]: f"{kind} {detail}".strip(), rule.tool or ""] +def _enum_name(value) -> str: + """An enum as its own name, not its Python repr. + + The SDK hands back `WorkflowPolicyAction.SET_VERSION`; an operator reading an + audit trail wants `set_version`. + """ + return str(getattr(value, "value", value)).rsplit(".", 1)[-1].lower() + + def _state_of(w) -> str: state = getattr(w, "workflow_state", None) return getattr(state, "value", state) or "unknown" @@ -1648,8 +1657,8 @@ async def go(): f"{(e.answer or {}).get('text', '')}") else: typer.echo(f"{stamp} policy fired: " - f"metric={getattr(e, 'metric', '')} " - f"action={getattr(e, 'action', '')}") + f"metric={_enum_name(getattr(e, 'metric', ''))} " + f"action={_enum_name(getattr(e, 'action', ''))}") asyncio.run(go()) diff --git a/examples/README.md b/examples/README.md index 90b5768..a927cd0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,9 +5,11 @@ with four tickets and the charges behind them, so both run with nothing but a model key. refund-triage reads a ticket, decides, and refunds. The refund stops - for a human before it goes through. + for a human, and the agent pauses itself if too many are + turned down. ticket-summarizer reads every open ticket and reports what needs attention. - Two versions, so a rollback has somewhere to go. + Runs unattended on v2, and rolls itself back to v1 when + it spends too much. ## Running them @@ -65,9 +67,50 @@ The agent will keep revising while you keep giving it reasons, so `runtime.yaml` caps `support__create_refund` at three calls per task. The objective asks it to revise rather than repeat; the ceiling is what holds when it doesn't. +Turning refunds down often enough says something about the agent rather than the +task, and `lifecycle.yaml` acts on that: four rejections across the last three +runs and it pauses itself. + + AGENT INSTANCE VER STATUS ACTIVITY + refund-triage 5054d8e3 v1 paused active + + 1 stopped — no new tasks will start + +Further runs are refused until `charter resume refund-triage --instance `. +One run can propose at most three refunds, so this can only be a pattern across +runs, which is the difference between the two policy files: `runtime.yaml` bounds +one task, `lifecycle.yaml` reacts to several. + `charter status ` is where you read the outcome, and `charter ui` does all of this in a browser, across every agent at once. +## Rolling a version back + +`ticket-summarizer` has no gated tools, so nothing stops for a human. It has two +versions: v1 uses a cheaper model and runs when you ask, v2 uses a better one and +runs every fifteen minutes on its own. `lifecycle.yaml` says what to do if v2 is +not worth it: + + - when: { metric: cost, threshold: 0.05 } + then: { set_version: { target: 1 } } + +Create it and let it run: + + charter agent create ticket-summarizer + charter apply . --all + charter run ticket-summarizer --instance + +It starts on v2. Once v2 has spent five cents the control plane puts v1 back, +with nobody watching: + + AGENT INSTANCE VER STATUS ACTIVITY + ticket-summarizer b4a3491a v1 active active + + charter audit ticket-summarizer --instance b4a3491a + 2026-09-08 22:12 policy fired: metric=cost action=set_version + +`charter describe` shows the rules and what the running version has spent so far. + ## What each file is for `v1.yaml` is behaviour, and it is versioned: you write a v2 rather than editing diff --git a/examples/refund-triage/lifecycle.yaml b/examples/refund-triage/lifecycle.yaml index a629af7..0a35474 100644 --- a/examples/refund-triage/lifecycle.yaml +++ b/examples/refund-triage/lifecycle.yaml @@ -4,16 +4,7 @@ kind: LifecyclePolicy agent: refund-triage rules: - # If 4 approvals are rejected in the last 3 runs, stay paused until someone - # runs `charter resume`. + # If 4 refund attempts are rejected in the last 3 runs, stay paused until someone + # runs `charter resume`. One run may propose at most 3 times (see runtime policy), so this spans more than one run - when: { metric: approval_rejections, threshold: 4 } then: { pause: { window: 3 } } - - # If 2 tasks failed in the last 3 runs, pause the agent. - - when: { metric: num_failures, threshold: 2 } - then: { pause: { window: 3 } } - - # 5 dollar budget across 5 runs. If the agent crosses it, stop for five - # minutes, then continue. - - when: { metric: cost, threshold: 5.00 } - then: { cooldown: { window: 5, seconds: 300 } } diff --git a/examples/ticket-summarizer/lifecycle.yaml b/examples/ticket-summarizer/lifecycle.yaml index 46d0e0a..6c68365 100644 --- a/examples/ticket-summarizer/lifecycle.yaml +++ b/examples/ticket-summarizer/lifecycle.yaml @@ -4,8 +4,10 @@ kind: LifecyclePolicy agent: ticket-summarizer rules: + # v2 runs on a schedule and on a pricier model. Once it has spent this much, + # the control plane puts v1 back: cheaper model and only manually triggered + - when: { metric: cost, threshold: 0.05 } + then: { set_version: { target: 1 } } + - when: { metric: num_failures, threshold: 3 } then: { pause: { window: 5 } } - - - when: { metric: cost, threshold: 2.00 } - then: { set_version: { target: 1 } } From e9e3a34b86e7a9b95a9f56f22be66ed82634e1a5 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 18:42:56 -0400 Subject: [PATCH 15/49] Stop restating the table under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `charter agents` printed 'N waiting on a human' and 'N stopped — no new tasks will start' beneath a table whose STATUS and ACTIVITY columns already said both. What the table does not carry is the command for each, so that is what is left. `describe` printed the same sentence under a line showing the same state. --- charter/cli.py | 10 +++++----- examples/README.md | 4 +++- tests/test_cli.py | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/charter/cli.py b/charter/cli.py index 39d2918..71ef02b 100644 --- a/charter/cli.py +++ b/charter/cli.py @@ -944,17 +944,19 @@ async def go(): if w.lifecycle_state.value in ("awaiting_approval", "awaiting_input")] stopped = [w for w in mine if not ui.working(w.workflow_state.value)] + # The table already carries both states, so these are the commands + # for them and not a second telling of what it says. if waiting: typer.echo() - ui.warn(f"{len(waiting)} waiting on a human") + ui.warn("awaiting approval") for w in waiting: ui.detail(f"charter pending {w.workflow_type} --instance {short(w.id)}") if stopped: typer.echo() - ui.warn(f"{len(stopped)} stopped — no new tasks will start") + ui.warn("stopped") for w in stopped: - ui.detail(f"charter audit {w.workflow_type} --instance {short(w.id)}") ui.detail(f"charter resume {w.workflow_type} --instance {short(w.id)}") + ui.detail(f"charter audit {w.workflow_type} --instance {short(w.id)}") asyncio.run(go()) @@ -1135,8 +1137,6 @@ async def go(): line = (f"{agent} v{wf.version} {ui.state(wf.workflow_state.value)}" f" {ui.state(wf.lifecycle_state.value)}") typer.echo(line) - if not ui.working(wf.workflow_state.value): - ui.warn(f" stopped — no new tasks will start") m = await cp.get_workflow_metrics(wf.id) typer.echo(f"\n {m.run_count} run(s), ${m.total_cost_usd:.4f}, " diff --git a/examples/README.md b/examples/README.md index a927cd0..c40b89b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -74,7 +74,9 @@ runs and it pauses itself. AGENT INSTANCE VER STATUS ACTIVITY refund-triage 5054d8e3 v1 paused active - 1 stopped — no new tasks will start + stopped + charter resume refund-triage --instance 5054d8e3 + charter audit refund-triage --instance 5054d8e3 Further runs are refused until `charter resume refund-triage --instance `. One run can propose at most three refunds, so this can only be a pattern across diff --git a/tests/test_cli.py b/tests/test_cli.py index ac980d8..a22a115 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -167,7 +167,7 @@ def test_points_a_parked_agent_at_pending(self, cp): cp.workflows = [workflow("refund-demo", lifecycle_state=LifecycleState.AWAITING_APPROVAL)] out = invoke("agents").output - assert "waiting on a human" in out + assert "awaiting approval" in out assert "charter pending refund-demo" in out def test_points_a_paused_agent_at_resume(self, cp): From 74e9699d7c9fea087b33ad6bd57bf8d1e3ee73ee Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 19:14:20 -0400 Subject: [PATCH 16/49] Say what window means The field descriptions said window was how many recent tasks the metric sums over, and left out that a rule is not evaluated until that many tasks exist. An agent with window 10 and four tasks behind it has no rule evaluated, whatever its metrics say. Reading it as a plain lookback is how I spent an afternoon concluding a working feature was broken. DESIGN.md also now says why set_version has no window: it compares totals for the running version. --- DESIGN.md | 6 ++++++ charter/config/lifecycle.py | 8 ++++++-- examples/refund-triage/lifecycle.yaml | 2 ++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index df19eb2..1524764 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -678,6 +678,12 @@ exactly one of `pause: {window}`, `cooldown: {window, seconds}`, or `set_version: {target}`. A `set_version` target must exist on disk *and* appear in `serves[].versions` for every worker running the agent. +`window` is two things: how many recent tasks the metric sums over, and how many +must have run before the rule is evaluated at all. An agent with `window: 10` and +four tasks behind it has no rule evaluated yet, whatever its metrics say. +`set_version` takes no window, because it compares totals for the version now +running, which reset when the version changes. + ### Worker — `worker.yaml` Not versioned. Every secret is an `${ENV_VAR}` reference, never a literal. diff --git a/charter/config/lifecycle.py b/charter/config/lifecycle.py index 627958e..6792550 100644 --- a/charter/config/lifecycle.py +++ b/charter/config/lifecycle.py @@ -66,13 +66,17 @@ def _check(self) -> When: class Pause(Base): """Hold all new tasks until `charter resume`. Queued tasks wait, not discarded.""" - window: int = Field(gt=0, description="How many recent tasks the metric sums over.") + window: int = Field(gt=0, description=( + "How many recent tasks the metric sums over, and the number that must have " + "run before the rule is evaluated at all.")) class Cooldown(Base): """Pause, then auto-resume after `seconds`.""" - window: int = Field(gt=0) + window: int = Field(gt=0, description=( + "How many recent tasks the metric sums over, and the number that must have " + "run before the rule is evaluated at all.")) seconds: int = Field(gt=0) diff --git a/examples/refund-triage/lifecycle.yaml b/examples/refund-triage/lifecycle.yaml index 0a35474..fcd9456 100644 --- a/examples/refund-triage/lifecycle.yaml +++ b/examples/refund-triage/lifecycle.yaml @@ -3,6 +3,8 @@ kind: LifecyclePolicy agent: refund-triage +# `window` is how many recent runs the metric sums over, and how many must have +# run before the rule is evaluated at all. rules: # If 4 refund attempts are rejected in the last 3 runs, stay paused until someone # runs `charter resume`. One run may propose at most 3 times (see runtime policy), so this spans more than one run From 554a3a10659b9b9f51f15f72dda3bb9ec2bc7fda Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 19:31:50 -0400 Subject: [PATCH 17/49] Describe window as the lookback it is The minimum-sample-size note goes away with the behaviour it described. Cooldown keeps a description, which it never had. DESIGN.md keeps the one part still true: set_version takes no window, because it compares totals for the version now running. --- DESIGN.md | 7 ++----- charter/config/lifecycle.py | 8 ++------ examples/refund-triage/lifecycle.yaml | 2 -- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 1524764..45a66e5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -678,11 +678,8 @@ exactly one of `pause: {window}`, `cooldown: {window, seconds}`, or `set_version: {target}`. A `set_version` target must exist on disk *and* appear in `serves[].versions` for every worker running the agent. -`window` is two things: how many recent tasks the metric sums over, and how many -must have run before the rule is evaluated at all. An agent with `window: 10` and -four tasks behind it has no rule evaluated yet, whatever its metrics say. -`set_version` takes no window, because it compares totals for the version now -running, which reset when the version changes. +`set_version` takes no window: it compares totals for the version now running, +which reset when the version changes. ### Worker — `worker.yaml` diff --git a/charter/config/lifecycle.py b/charter/config/lifecycle.py index 6792550..4ead50b 100644 --- a/charter/config/lifecycle.py +++ b/charter/config/lifecycle.py @@ -66,17 +66,13 @@ def _check(self) -> When: class Pause(Base): """Hold all new tasks until `charter resume`. Queued tasks wait, not discarded.""" - window: int = Field(gt=0, description=( - "How many recent tasks the metric sums over, and the number that must have " - "run before the rule is evaluated at all.")) + window: int = Field(gt=0, description="How many recent tasks the metric sums over.") class Cooldown(Base): """Pause, then auto-resume after `seconds`.""" - window: int = Field(gt=0, description=( - "How many recent tasks the metric sums over, and the number that must have " - "run before the rule is evaluated at all.")) + window: int = Field(gt=0, description="How many recent tasks the metric sums over.") seconds: int = Field(gt=0) diff --git a/examples/refund-triage/lifecycle.yaml b/examples/refund-triage/lifecycle.yaml index fcd9456..0a35474 100644 --- a/examples/refund-triage/lifecycle.yaml +++ b/examples/refund-triage/lifecycle.yaml @@ -3,8 +3,6 @@ kind: LifecyclePolicy agent: refund-triage -# `window` is how many recent runs the metric sums over, and how many must have -# run before the rule is evaluated at all. rules: # If 4 refund attempts are rejected in the last 3 runs, stay paused until someone # runs `charter resume`. One run may propose at most 3 times (see runtime policy), so this spans more than one run From c3cdd496110c0531284ffdc357ef22bf5f8ced7c Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 20:38:17 -0400 Subject: [PATCH 18/49] Name the config block after the fields it shows "if a worker dies / another picks it up" described one cause of a resumed run, and `resumable` covers any infrastructure failure: an expired lease, a cancelled operation, a worker that went away. Each row is now a field and a value, using the config's own names, so a value in `describe` can be found in the YAML that set it. triggerable and invoke mode were folded into prose in other rows and are their own now. --- charter/cli.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/charter/cli.py b/charter/cli.py index 71ef02b..efda441 100644 --- a/charter/cli.py +++ b/charter/cli.py @@ -1220,20 +1220,21 @@ def _config_lines(cfg) -> list[tuple[str, object]]: rather than the absence it is.""" if cfg is None: return [("config", "none on the control plane — charter apply")] - schedule = (f"every {_duration(cfg.repeat_every_seconds)}" - if cfg.repeat_every_seconds else "on demand") - if not cfg.triggerable: - schedule += ", no manual runs" - queued = (f", max {cfg.max_queue_depth} queued" if cfg.max_queue_depth - else ", server default depth" if cfg.invoke_mode.value == "queue" else "") - return [("runs", schedule), - ("piled-up invokes", cfg.invoke_mode.value + queued), - ("if a worker dies", "another picks it up" if cfg.resumable - else "the workflow is interrupted until someone clears it"), + # Named after the fields they come from, so a value here can be found in the + # YAML that set it. + return [("schedule", f"every {_duration(cfg.repeat_every_seconds)}" + if cfg.repeat_every_seconds else "on demand"), + ("triggerable", "yes" if cfg.triggerable else "no"), + ("invoke mode", cfg.invoke_mode.value), + ("queue depth", str(cfg.max_queue_depth) if cfg.max_queue_depth + else "server default" if cfg.invoke_mode.value == "queue" else "-"), + # Retries the run on another worker when the infrastructure fails: a + # dead worker, an expired lease, a cancelled operation. + ("resumable", "yes" if cfg.resumable else "no"), # Not "round deadline": `round` is an internal unit, and the whole # point of replacing max_iterations with drafts/questions/tool-failures # was that nobody should have to know what one is. - ("cancelled after", _duration(cfg.invoke_timeout_seconds))] + ("timeout", _duration(cfg.invoke_timeout_seconds))] def _stamp(ts) -> str: From 739740b108a858f54a8947e149e4bc88faf8a4dd Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 20:46:05 -0400 Subject: [PATCH 19/49] Show everything the control plane holds The gate printed a sentence built from two of its five fields and dropped the rest. metadata carries the tool and its arguments as data, and opened_at says whether a decision has been waiting two minutes or two hours. Both are now on screen, with the approval id as a field rather than only inside a command. status gains the request's kind, sequence, status and timeout, and the runtime policy that was in force for that run, which is what it was actually held to rather than whatever apply has changed since. The metrics block gains working time and per-tool failures. Working time is BoundFlow's total_latency_seconds: summed across runs, excluding time parked at a gate, which is why it is not called latency next to a wall-clock took. create_refund takes a reason, so the agent's rationale reaches the approver instead of staying in its head. playground's tool always had one. --- charter/cli.py | 57 ++++++++++++++++++++++++++++++---- charter/ui.py | 10 ++++-- examples/refund-triage/v1.yaml | 5 +-- examples/support_server.py | 10 ++++-- 4 files changed, 68 insertions(+), 14 deletions(-) diff --git a/charter/cli.py b/charter/cli.py index efda441..7b7e4cb 100644 --- a/charter/cli.py +++ b/charter/cli.py @@ -1085,12 +1085,18 @@ async def go(): ("runs", metrics.run_count), ("cost", f"${metrics.total_cost_usd:.4f}"), ("llm calls", metrics.total_llm_calls), + # BoundFlow's total_latency_seconds. Working time summed over + # runs, so a gate someone answered tomorrow adds nothing. + ("working time", f"{metrics.total_latency_seconds:.1f}s"), ("failures", metrics.total_failures), - ("rejections", metrics.total_approval_rejections)], indent=" ") + ("rejections", metrics.total_approval_rejections), + ("tool failures", ", ".join(f"{t}={n}" for t, n + in sorted(metrics.tool_failure_counts.items())) + or "none")], indent=" ") if wf.pending_approval: g = wf.pending_approval - ui.gate(agent, "approval", g.approval_id, g.justification, [ + ui.gate(agent, "approval", g.approval_id, g.justification, fields=_gate_fields(g, "approval"), actions=[ f"charter approve {g.approval_id} --agent {agent} " f"--instance {short(wf.id)} --reason '...'", f"charter reject {g.approval_id} --agent {agent} " @@ -1098,7 +1104,7 @@ async def go(): ], timeout=_when(g.timeout_at)) elif wf.pending_input: g = wf.pending_input - ui.gate(agent, "an answer", g.input_id, g.prompt, [ + ui.gate(agent, "an answer", g.input_id, g.prompt, fields=_gate_fields(g, "input"), actions=[ f"charter answer {g.input_id} '...' --agent {agent} " f"--instance {short(wf.id)}"], timeout=_when(g.timeout_at)) @@ -1237,6 +1243,29 @@ def _config_lines(cfg) -> list[tuple[str, object]]: ("timeout", _duration(cfg.invoke_timeout_seconds))] +def _gate_fields(g, kind: str) -> list[tuple[str, object]]: + """Everything the control plane holds about an open gate. + + `metadata` carries the tool and its arguments as data rather than as the + sentence built from them, and an approval that has been open for two hours is + a different decision from one raised a minute ago. + """ + meta = dict(getattr(g, "metadata", None) or {}) + rows: list[tuple[str, object]] = [(kind, getattr(g, "approval_id", None) + or getattr(g, "input_id", ""))] + if tool := meta.pop("tool", ""): + rows.append(("tool", tool)) + if args := meta.pop("args", None): + rows.append(("args", ", ".join(f"{k}={v!r}" for k, v in args.items()) + if isinstance(args, dict) else args)) + rows += sorted(meta.items()) + if opened := getattr(g, "opened_at", None): + rows.append(("opened", _stamp(opened))) + if until := getattr(g, "timeout_at", None): + rows.append(("expires", _stamp(until))) + return rows + + def _stamp(ts) -> str: """A timestamp to the second, date included — `_when` gives clock time only, which is ambiguous for a hold placed yesterday. "-" for None, because "never" @@ -1355,9 +1384,14 @@ async def go(): return outcome = info.run_outcome.value if info.run_outcome else info.status.value ui.kv([("task", task_id), + ("agent", short(info.workflow_id) if info.workflow_id else ""), + ("kind", _enum_name(info.request_type) if info.request_type else ""), + ("sequence", info.sequence_number), + ("status", _enum_name(info.status)), ("outcome", ui.state(outcome)), ("started", info.created_at.strftime("%Y-%m-%d %H:%M:%S") if info.created_at else ""), - ("took", _took(info.created_at, info.completed_at) or "-")]) + ("took", _took(info.created_at, info.completed_at) or "-"), + ("timeout", _duration(info.timeout_seconds) if info.timeout_seconds else "")]) # An uncaught exception never got far enough to publish a result, so # failure_reason is the only record of it. Printed whole — a truncated @@ -1368,6 +1402,17 @@ async def go(): for line in info.failure_reason.splitlines(): ui.detail(line) + # The limits this run was actually under, which are the ones armed when + # it started rather than whatever `charter apply` has since changed. + if policies := getattr(info, "agent_runtime_policies", None): + typer.echo() + typer.secho("policy in force", fg=typer.colors.BRIGHT_BLACK) + for agent_name, policy in sorted(dict(policies).items()): + if len(policies) > 1: + ui.detail(agent_name) + ui.kv([(_snake(k), _fmt(v)) for k, v in sorted(dict(policy).items())], + indent=" ") + if info.invoke_context: given = {k: v for k, v in info.invoke_context.items() if not k.startswith("_")} if given: @@ -1420,7 +1465,7 @@ async def go(): if wf.pending_approval: g = wf.pending_approval - ui.gate(agent, "approval", g.approval_id, g.justification, [ + ui.gate(agent, "approval", g.approval_id, g.justification, fields=_gate_fields(g, "approval"), actions=[ f"charter approve {g.approval_id} --agent {agent} " f"--instance {short(wf.id)} --reason '...'", f"charter reject {g.approval_id} --agent {agent} " @@ -1428,7 +1473,7 @@ async def go(): ], timeout=_when(g.timeout_at)) elif wf.pending_input: g = wf.pending_input - ui.gate(agent, "an answer", g.input_id, g.prompt, [ + ui.gate(agent, "an answer", g.input_id, g.prompt, fields=_gate_fields(g, "input"), actions=[ f"charter answer {g.input_id} '...' --agent {agent} " f"--instance {short(wf.id)}", ], timeout=_when(g.timeout_at)) diff --git a/charter/ui.py b/charter/ui.py index a98d564..4ef4f1d 100644 --- a/charter/ui.py +++ b/charter/ui.py @@ -112,8 +112,9 @@ def working(workflow_state: str) -> bool: return workflow_state == "active" -def gate(agent: str, kind: str, gate_id: str, body: str, actions: list[str], - timeout: str = "") -> None: +def gate(agent: str, kind: str, gate_id: str, body: str, + actions: list[str] | None = None, timeout: str = "", + fields: list[tuple[str, object]] | None = None) -> None: """The one screen that should slow you down. Everything else here is built to be skimmed; this is a person deciding whether @@ -132,8 +133,11 @@ def gate(agent: str, kind: str, gate_id: str, body: str, actions: list[str], typer.echo() for line in body.strip().splitlines(): typer.echo(f" {line}") + if fields: + typer.echo() + kv(fields, indent=" ") typer.echo() - for action in actions: + for action in actions or []: typer.secho(f" {action}", fg=typer.colors.BRIGHT_BLACK) typer.echo() diff --git a/examples/refund-triage/v1.yaml b/examples/refund-triage/v1.yaml index a850b4f..840ec40 100644 --- a/examples/refund-triage/v1.yaml +++ b/examples/refund-triage/v1.yaml @@ -14,8 +14,9 @@ objective: | than ${{ inputs.max_refund_usd }}. If the customer's reason is unclear, or the charge doesn't match what they describe, ask rather than guess. - Refunds stop for a human before they go through, so say plainly what you are - about to do and why: that sentence is what the approver reads. + Refunds stop for a human before they go through. Put your reasoning in the + refund's `reason`: that sentence is what the approver reads, and the only thing + they have to go on. If a refund is refused, the approver's reason tells you what was wrong with it. Use it: propose a corrected refund if the reason points at one. Never re-propose diff --git a/examples/support_server.py b/examples/support_server.py index 2a3fe1b..01b92d8 100644 --- a/examples/support_server.py +++ b/examples/support_server.py @@ -80,8 +80,12 @@ def get_charge(charge_id: str) -> dict: @mcp.tool(annotations=MUTATES) -def create_refund(charge_id: str, amount_usd: float) -> dict: - """Refund against a charge. Gated: a person approves this before it runs.""" +def create_refund(charge_id: str, amount_usd: float, reason: str) -> dict: + """Refund against a charge. Gated: a person approves this before it runs. + + `reason` is what the approver reads, so it travels with the call rather than + staying in the agent's head. + """ if charge_id not in CHARGES: raise ValueError(f"no charge {charge_id}") charge = CHARGES[charge_id] @@ -90,7 +94,7 @@ def create_refund(charge_id: str, amount_usd: float) -> dict: raise ValueError( f"{amount_usd} exceeds the {outstanding} still refundable on {charge_id}") charge["refunded_usd"] += amount_usd - return {"charge_id": charge_id, "refunded_usd": amount_usd, + return {"charge_id": charge_id, "refunded_usd": amount_usd, "reason": reason, "remaining_usd": charge["amount_usd"] - charge["refunded_usd"]} From 2b5cb87be08df7b6214ac8a2ba637792bd9208ed Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 20:48:56 -0400 Subject: [PATCH 20/49] Stop collapsing states that need different acts `runs` folded status and outcome into one column, so a request that completed with an unsuccessful run looked like one that failed, and dropped request_type entirely. Both are columns now. `agents` sorted an agent in cooldown into 'stopped' and told you to resume it, which is not a thing you need to do: it starts again on its own. Cooling down and deleting are their own groups, carrying the times BoundFlow already gave us. --- charter/cli.py | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/charter/cli.py b/charter/cli.py index 7b7e4cb..dd36482 100644 --- a/charter/cli.py +++ b/charter/cli.py @@ -942,7 +942,12 @@ async def go(): # The two reasons an agent isn't working, and they need different acts. waiting = [w for w in mine if w.lifecycle_state.value in ("awaiting_approval", "awaiting_input")] - stopped = [w for w in mine if not ui.working(w.workflow_state.value)] + # Cooling down is not stopped: it starts again on its own, so it gets + # the time rather than a resume nobody needs to run. + cooling = [w for w in mine if w.cooldown_until] + deleting = [w for w in mine if w.deletion_requested_at] + stopped = [w for w in mine if not ui.working(w.workflow_state.value) + and not w.cooldown_until and not w.deletion_requested_at] # The table already carries both states, so these are the commands # for them and not a second telling of what it says. @@ -951,6 +956,18 @@ async def go(): ui.warn("awaiting approval") for w in waiting: ui.detail(f"charter pending {w.workflow_type} --instance {short(w.id)}") + if cooling: + typer.echo() + ui.warn("cooling down") + for w in cooling: + ui.detail(f"{w.workflow_type} {short(w.id)} until " + f"{_stamp(w.cooldown_until)}") + if deleting: + typer.echo() + ui.warn("deleting") + for w in deleting: + ui.detail(f"{w.workflow_type} {short(w.id)} requested " + f"{_stamp(w.deletion_requested_at)}") if stopped: typer.echo() ui.warn("stopped") @@ -1170,15 +1187,21 @@ async def go(): ui.dim(f"no matching tasks ({total} total)") return reasons = await _reasons(cp, shown) - ui.table(["task", "outcome", "started", "took"], + # status and outcome are different answers: status is where the request + # got to, outcome is what the run decided. A run can be completed and + # unsuccessful. + ui.table(["task", "kind", "status", "outcome", "started", "took"], [[r.request_id, - ui.state((r.run_outcome or r.status).value), + _enum_name(r.request_type) if r.request_type else "", + _enum_name(r.status), + ui.state(r.run_outcome.value) if r.run_outcome else "", r.created_at.strftime("%m-%d %H:%M") if r.created_at else "", _took(r.created_at, r.completed_at)] for r in shown], notes=[_first_line(reasons.get(r.request_id, r.failure_reason)) for r in shown]) if len(shown) < len(runs): - ui.dim(f" {len(shown)} of {len(runs)} matching ({total} total) — -n 0 for all") + ui.dim(f" {len(shown)} of {len(runs)} matching, {total} total. " + f"-n 0 for all") asyncio.run(go()) From 55336c1f97fdcd1b4b22590cd78520ee5b9efcfb Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 20:52:33 -0400 Subject: [PATCH 21/49] Ask the refund's reason for evidence The reason said what it was for and never what it must contain, so the agent filled it with a summary the approver cannot check. The tool description now asks for the ticket, the charges compared, and why this amount, with an example of each kind. The objective says the same, and says why: the approver cannot see the tickets the agent read. --- examples/refund-triage/v1.yaml | 6 +++--- examples/support_server.py | 9 ++++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/examples/refund-triage/v1.yaml b/examples/refund-triage/v1.yaml index 840ec40..8cae829 100644 --- a/examples/refund-triage/v1.yaml +++ b/examples/refund-triage/v1.yaml @@ -14,9 +14,9 @@ objective: | than ${{ inputs.max_refund_usd }}. If the customer's reason is unclear, or the charge doesn't match what they describe, ask rather than guess. - Refunds stop for a human before they go through. Put your reasoning in the - refund's `reason`: that sentence is what the approver reads, and the only thing - they have to go on. + Refunds stop for a human before they go through, and the refund's `reason` is + all the approver sees. Put the evidence in it: the ticket, the charges you + looked up, and why this amount. They cannot see the tickets you read. If a refund is refused, the approver's reason tells you what was wrong with it. Use it: propose a corrected refund if the reason points at one. Never re-propose diff --git a/examples/support_server.py b/examples/support_server.py index 01b92d8..bf1c65e 100644 --- a/examples/support_server.py +++ b/examples/support_server.py @@ -81,10 +81,13 @@ def get_charge(charge_id: str) -> dict: @mcp.tool(annotations=MUTATES) def create_refund(charge_id: str, amount_usd: float, reason: str) -> dict: - """Refund against a charge. Gated: a person approves this before it runs. + """Refund against a charge. A person approves this before it runs. - `reason` is what the approver reads, so it travels with the call rather than - staying in the agent's head. + reason: the evidence, not a summary. Name the ticket, the charges you compared + and what each was for, and why this amount rather than another. "Order #4417 + charged twice on the 3rd, ch_88213 and ch_88301 at $48.00 each, refunding the + duplicate" is useful. "Customer requested a refund" is not: the approver + cannot check it. """ if charge_id not in CHARGES: raise ValueError(f"no charge {charge_id}") From 6408b675572414a61f0bee7a8ff3221bf4239cd8 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 20:56:20 -0400 Subject: [PATCH 22/49] Stop repeating the call above the fields that carry it The gate printed 'agent wants to call TOOL with args...' and then listed tool and args underneath. The sentence stays in , which a notification and the console carry on their own, and the CLI now prints only what the harness added beyond it. Only when metadata carries the tool: a justification with nothing below it repeating it is still printed whole. --- charter/cli.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/charter/cli.py b/charter/cli.py index dd36482..d1b1cc1 100644 --- a/charter/cli.py +++ b/charter/cli.py @@ -1113,7 +1113,7 @@ async def go(): if wf.pending_approval: g = wf.pending_approval - ui.gate(agent, "approval", g.approval_id, g.justification, fields=_gate_fields(g, "approval"), actions=[ + ui.gate(agent, "approval", g.approval_id, _gate_body(g), fields=_gate_fields(g, "approval"), actions=[ f"charter approve {g.approval_id} --agent {agent} " f"--instance {short(wf.id)} --reason '...'", f"charter reject {g.approval_id} --agent {agent} " @@ -1266,6 +1266,20 @@ def _config_lines(cfg) -> list[tuple[str, object]]: ("timeout", _duration(cfg.invoke_timeout_seconds))] +def _gate_body(g) -> str: + """The justification without its opening sentence. + + That sentence names the tool and its arguments, because a notification carries + `justification` alone. Here the fields below carry both, so only anything the + harness added is worth the space. + """ + text = (getattr(g, "justification", "") or "").strip() + if not dict(getattr(g, "metadata", None) or {}).get("tool"): + return text # nothing below repeats it, so print all of it + parts = text.split("\n\n", 1) + return parts[1].strip() if len(parts) > 1 else "" + + def _gate_fields(g, kind: str) -> list[tuple[str, object]]: """Everything the control plane holds about an open gate. @@ -1488,7 +1502,7 @@ async def go(): if wf.pending_approval: g = wf.pending_approval - ui.gate(agent, "approval", g.approval_id, g.justification, fields=_gate_fields(g, "approval"), actions=[ + ui.gate(agent, "approval", g.approval_id, _gate_body(g), fields=_gate_fields(g, "approval"), actions=[ f"charter approve {g.approval_id} --agent {agent} " f"--instance {short(wf.id)} --reason '...'", f"charter reject {g.approval_id} --agent {agent} " From 896167a86b3f0d28afa097f2abc30b02678ecf6d Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 23:17:07 -0400 Subject: [PATCH 23/49] Give each argument of a gated call its own row The arguments were joined into one line, which put the agent's reasoning at the end of a string nobody reads to the end of. The reason it passes is the point of the screen, so it gets a row like every other argument. --- charter/cli.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/charter/cli.py b/charter/cli.py index d1b1cc1..697a38e 100644 --- a/charter/cli.py +++ b/charter/cli.py @@ -1292,9 +1292,13 @@ def _gate_fields(g, kind: str) -> list[tuple[str, object]]: or getattr(g, "input_id", ""))] if tool := meta.pop("tool", ""): rows.append(("tool", tool)) - if args := meta.pop("args", None): - rows.append(("args", ", ".join(f"{k}={v!r}" for k, v in args.items()) - if isinstance(args, dict) else args)) + args = meta.pop("args", None) + if isinstance(args, dict): + # One row each. The argument carrying the agent's reasoning is the point of + # the screen, and it is unreadable joined onto the end of the others. + rows += [(k, v) for k, v in args.items()] + elif args: + rows.append(("args", args)) rows += sorted(meta.items()) if opened := getattr(g, "opened_at", None): rows.append(("opened", _stamp(opened))) From c146ab1cab13bc84a23ee93958e34be1222cf9e1 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 23:27:55 -0400 Subject: [PATCH 24/49] Ask the agent why, on any tool that stops for a human A gate could only ever show the call and its arguments: the harness hands Charter a tool call and nothing else, and the description it attaches says "Tool execution requires approval". So an approver saw what was about to happen and no account of why. Charter now adds a required `why` to the schema of every tool marked `approval: always`, and strips it before dispatch, since the server never declared it. It leads the justification, which is the field a notification and the console carry on their own, and it gets its own row on the gate. Only gated tools: nobody reviews the rest, so asking the model to justify them spends tokens on text nobody reads. The example's create_refund had grown a `reason` parameter to work around this. It is gone, and the example no longer needs a tool it controls to demonstrate the feature. --- charter/cli.py | 15 +++++------ charter/mcp/client.py | 46 ++++++++++++++++++++++++++++++++++ charter/workflows/loop.py | 7 +++++- examples/refund-triage/v1.yaml | 6 ++--- examples/support_server.py | 13 +++------- tests/test_agent_config.py | 38 ++++++++++++++++++++++++++++ tests/test_loop.py | 12 +++++++++ 7 files changed, 116 insertions(+), 21 deletions(-) diff --git a/charter/cli.py b/charter/cli.py index 697a38e..f21906d 100644 --- a/charter/cli.py +++ b/charter/cli.py @@ -1274,10 +1274,9 @@ def _gate_body(g) -> str: harness added is worth the space. """ text = (getattr(g, "justification", "") or "").strip() - if not dict(getattr(g, "metadata", None) or {}).get("tool"): - return text # nothing below repeats it, so print all of it - parts = text.split("\n\n", 1) - return parts[1].strip() if len(parts) > 1 else "" + if dict(getattr(g, "metadata", None) or {}).get("tool"): + return "" # the fields below carry the call, the why and the args + return text def _gate_fields(g, kind: str) -> list[tuple[str, object]]: @@ -1294,9 +1293,11 @@ def _gate_fields(g, kind: str) -> list[tuple[str, object]]: rows.append(("tool", tool)) args = meta.pop("args", None) if isinstance(args, dict): - # One row each. The argument carrying the agent's reasoning is the point of - # the screen, and it is unreadable joined onto the end of the others. - rows += [(k, v) for k, v in args.items()] + args = dict(args) + if why := str(args.pop("why", "")).strip(): + rows.append(("why", why)) + if args: + rows.append(("args", ", ".join(f"{k}={v!r}" for k, v in args.items()))) elif args: rows.append(("args", args)) rows += sorted(meta.items()) diff --git a/charter/mcp/client.py b/charter/mcp/client.py index 35d1bbe..24c9b50 100644 --- a/charter/mcp/client.py +++ b/charter/mcp/client.py @@ -339,6 +339,8 @@ def langchain_tools(self) -> list: for server in self.servers.values(): for name, tool in server.tools.items(): tool.name = server.spec.qualified(name) + if server.gated(name): + tool = _explained(tool) out.append(_bounded(tool, getattr(self, "_tool_seconds", 0.0))) return out @@ -360,6 +362,50 @@ async def __aexit__(self, *exc) -> None: await self.aclose() +WHY = "why" + + +def _explained(tool): + """A gated tool takes `why`, so the agent states its case before a person reads it. + + The harness hands Charter a tool call and nothing else, so without this the + only account of a gated action is its arguments. Added to the schema the model + sees and stripped before the server is called: the field is Charter's, and a + tool that never declared it would reject the call. + """ + schema = dict(getattr(tool, "args_schema", None) or {}) + props = dict(schema.get("properties") or {}) + if WHY in props: + return tool + props[WHY] = { + "type": "string", "title": "Why", + "description": ("Why this call should go ahead, with the evidence for it. " + "A person reads this and nothing else before deciding, so " + "name what you looked at, not that you looked."), + } + schema["properties"] = props + schema["required"] = list(schema.get("required") or []) + [WHY] + tool.args_schema = schema + + if tool.coroutine: + inner = tool.coroutine + + async def run(*args, **kwargs): + kwargs.pop(WHY, None) + return await inner(*args, **kwargs) + + tool.coroutine = run + elif tool.func: + inner_fn = tool.func + + def run_sync(*args, **kwargs): + kwargs.pop(WHY, None) + return inner_fn(*args, **kwargs) + + tool.func = run_sync + return tool + + def _bounded(tool, seconds: float): """The tool, with a ceiling on how long one call may take.""" if not seconds: diff --git a/charter/workflows/loop.py b/charter/workflows/loop.py index 0d26ee3..71a459b 100644 --- a/charter/workflows/loop.py +++ b/charter/workflows/loop.py @@ -785,11 +785,16 @@ def _justify(self, action: dict) -> str: harness's line is appended only when it says something we didn't. """ name = action.get("name", "a tool") - args = action.get("args") or {} + args = dict(action.get("args") or {}) + # Charter's own field, asked of every gated tool. It leads, because it is + # the agent's case rather than a restatement of the call. + why = str(args.pop("why", "")).strip() detail = ", ".join(f"{k}={v!r}" for k, v in args.items()) line = f"{self.cfg.name} wants to call {name}" if detail: line += f" with {detail}" + if why: + line = f"{why}\n\n{line}" described = (action.get("description") or "").strip() if described and not described.lower().startswith("tool execution requires"): diff --git a/examples/refund-triage/v1.yaml b/examples/refund-triage/v1.yaml index 8cae829..d320d86 100644 --- a/examples/refund-triage/v1.yaml +++ b/examples/refund-triage/v1.yaml @@ -14,9 +14,9 @@ objective: | than ${{ inputs.max_refund_usd }}. If the customer's reason is unclear, or the charge doesn't match what they describe, ask rather than guess. - Refunds stop for a human before they go through, and the refund's `reason` is - all the approver sees. Put the evidence in it: the ticket, the charges you - looked up, and why this amount. They cannot see the tickets you read. + Refunds stop for a human before they go through, and your `why` is all the + approver sees. Put the evidence in it: the ticket, the charges you looked up, + and why this amount. They cannot see what you read. If a refund is refused, the approver's reason tells you what was wrong with it. Use it: propose a corrected refund if the reason points at one. Never re-propose diff --git a/examples/support_server.py b/examples/support_server.py index bf1c65e..c0f6a35 100644 --- a/examples/support_server.py +++ b/examples/support_server.py @@ -80,15 +80,8 @@ def get_charge(charge_id: str) -> dict: @mcp.tool(annotations=MUTATES) -def create_refund(charge_id: str, amount_usd: float, reason: str) -> dict: - """Refund against a charge. A person approves this before it runs. - - reason: the evidence, not a summary. Name the ticket, the charges you compared - and what each was for, and why this amount rather than another. "Order #4417 - charged twice on the 3rd, ch_88213 and ch_88301 at $48.00 each, refunding the - duplicate" is useful. "Customer requested a refund" is not: the approver - cannot check it. - """ +def create_refund(charge_id: str, amount_usd: float) -> dict: + """Refund against a charge. A person approves this before it runs.""" if charge_id not in CHARGES: raise ValueError(f"no charge {charge_id}") charge = CHARGES[charge_id] @@ -97,7 +90,7 @@ def create_refund(charge_id: str, amount_usd: float, reason: str) -> dict: raise ValueError( f"{amount_usd} exceeds the {outstanding} still refundable on {charge_id}") charge["refunded_usd"] += amount_usd - return {"charge_id": charge_id, "refunded_usd": amount_usd, "reason": reason, + return {"charge_id": charge_id, "refunded_usd": amount_usd, "remaining_usd": charge["amount_usd"] - charge["refunded_usd"]} diff --git a/tests/test_agent_config.py b/tests/test_agent_config.py index e9a2542..2351e0c 100644 --- a/tests/test_agent_config.py +++ b/tests/test_agent_config.py @@ -300,3 +300,41 @@ def test_starting_a_child_can_be_gated(): and that one is versioned, because it changes what stops for a human.""" cfg = AgentConfig.model_validate(load(gate={"tools": ["start_async_task"]})) assert "start_async_task" in cfg.gate.tools + + +class TestGatedToolsExplainThemselves: + """A gated call reaches a person with the agent's case for it. + + The harness hands Charter a tool call and nothing else, so a gate could only + ever show the arguments. `why` is Charter's, added to the schema the model + sees and removed before the server is called, which never declared it. + """ + + def _tool(self, coroutine=None, func=None, schema=None): + from types import SimpleNamespace + return SimpleNamespace( + name="support__create_refund", coroutine=coroutine, func=func, + args_schema=schema if schema is not None else { + "type": "object", + "properties": {"charge_id": {"type": "string"}}, + "required": ["charge_id"]}) + + def test_the_model_is_asked_for_it(self): + from charter.mcp.client import _explained + tool = _explained(self._tool(func=lambda **kw: kw)) + assert "why" in tool.args_schema["properties"] + assert "why" in tool.args_schema["required"] + + def test_the_server_never_sees_it(self): + """It is not a parameter the tool declared, so passing it through would + fail the call.""" + from charter.mcp.client import _explained + seen = {} + tool = _explained(self._tool(func=lambda **kw: seen.update(kw))) + tool.func(charge_id="ch_1", why="charged twice") + assert seen == {"charge_id": "ch_1"} + + def test_it_is_added_once(self): + from charter.mcp.client import _explained + tool = _explained(_explained(self._tool(func=lambda **kw: kw))) + assert tool.args_schema["required"].count("why") == 1 diff --git a/tests/test_loop.py b/tests/test_loop.py index fc2c707..fa99471 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -902,3 +902,15 @@ def test_a_policy_sourced_ceiling_parks_as_an_integer(): policy_custom={MAX_WAIT_SECONDS: 300.0}))) assert out.delay_seconds == 300 assert isinstance(out.delay_seconds, int) + + +def test_the_agents_why_leads_the_justification(): + """`justification` is the only field a notification carries, so the agent's + case for the call goes first and the call itself follows.""" + _, loop = loop_for() + text = loop._justify({"name": "support__create_refund", + "args": {"charge_id": "ch_1", "amount_usd": 48.0, + "why": "charged twice for order #4417"}}) + assert text.startswith("charged twice for order #4417") + assert "support__create_refund" in text + assert "why=" not in text, "why leads it rather than being listed as an argument" From 80ce0b5dce9ac8be80a4e20fef3b629b97547524 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 23:33:59 -0400 Subject: [PATCH 25/49] Let a tool opt out of being asked to justify itself `justify` on a gated tool, default true. A call whose arguments already say everything does not need a sentence about them, and the tokens are not free. The field the model fills is `justification`, which is what BoundFlow calls the thing it becomes, rather than a Charter word for the same idea. --- DESIGN.md | 7 +++++++ charter/cli.py | 4 ++-- charter/config/agent.py | 5 +++++ charter/mcp/client.py | 22 +++++++++++++--------- charter/workflows/loop.py | 12 +++++++----- examples/refund-triage/v1.yaml | 2 ++ tests/test_agent_config.py | 25 +++++++++++++++++++++---- tests/test_loop.py | 21 ++++++++++++++------- 8 files changed, 71 insertions(+), 27 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 45a66e5..5b21cff 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -434,6 +434,13 @@ tools: trace. - **`always`** — the call is intercepted and parks, every time. +A gated tool also gains a required `justification` argument, which the model fills +and Charter strips before calling the server. It becomes the gate's +`justification`, which is the only field a notification carries. The harness +supplies no reasoning of its own, so without it an approver sees the call and +nothing else. `justify: false` on a tool turns it off, for a call whose arguments +already say everything. + Set every tool to `never`, omit `gate`, and omit `ask_human`, and you have a fully autonomous agent that never asks anyone anything. It is still governed: per-task budget, tool limits, the full audit trail, and lifecycle rules diff --git a/charter/cli.py b/charter/cli.py index f21906d..0fe8af9 100644 --- a/charter/cli.py +++ b/charter/cli.py @@ -1294,8 +1294,8 @@ def _gate_fields(g, kind: str) -> list[tuple[str, object]]: args = meta.pop("args", None) if isinstance(args, dict): args = dict(args) - if why := str(args.pop("why", "")).strip(): - rows.append(("why", why)) + if stated := str(args.pop("justification", "")).strip(): + rows.append(("justification", stated)) if args: rows.append(("args", ", ".join(f"{k}={v!r}" for k, v in args.items()))) elif args: diff --git a/charter/config/agent.py b/charter/config/agent.py index 542c4dc..2fbcdd5 100644 --- a/charter/config/agent.py +++ b/charter/config/agent.py @@ -124,6 +124,11 @@ class ToolSpec(Base): "What a refusal of THIS tool means, overriding the agent's `gate.on_reject`. " "Rejecting an outreach message should let the rest of the run continue; " "rejecting a production deploy usually should not.")) + justify: bool = Field(default=True, description=( + "Gated tools only. Adds a required `justification` argument the model must " + "fill, which becomes the gate's justification and is stripped before the " + "server is called. Turn it off for a call whose arguments already say " + "everything, or where the extra tokens are not worth it.")) @property def gated(self) -> bool: diff --git a/charter/mcp/client.py b/charter/mcp/client.py index 24c9b50..0df8c55 100644 --- a/charter/mcp/client.py +++ b/charter/mcp/client.py @@ -221,6 +221,10 @@ def gated(self, tool: str) -> bool: declared = next(t for t in self.spec.tools if t.tool == tool) return declared.gated or tool in self.tightened + def justifies(self, tool: str) -> bool: + """Whether this tool asks the model to state its case.""" + return next(t for t in self.spec.tools if t.tool == tool).justify + class ToolSet: """Every MCP tool one agent version may use, loaded once at boot. @@ -339,7 +343,7 @@ def langchain_tools(self) -> list: for server in self.servers.values(): for name, tool in server.tools.items(): tool.name = server.spec.qualified(name) - if server.gated(name): + if server.gated(name) and server.justifies(name): tool = _explained(tool) out.append(_bounded(tool, getattr(self, "_tool_seconds", 0.0))) return out @@ -362,11 +366,11 @@ async def __aexit__(self, *exc) -> None: await self.aclose() -WHY = "why" +JUSTIFICATION = "justification" def _explained(tool): - """A gated tool takes `why`, so the agent states its case before a person reads it. + """A gated tool takes `justification`, so the agent states its case itself. The harness hands Charter a tool call and nothing else, so without this the only account of a gated action is its arguments. Added to the schema the model @@ -375,23 +379,23 @@ def _explained(tool): """ schema = dict(getattr(tool, "args_schema", None) or {}) props = dict(schema.get("properties") or {}) - if WHY in props: + if JUSTIFICATION in props: return tool - props[WHY] = { - "type": "string", "title": "Why", + props[JUSTIFICATION] = { + "type": "string", "title": "Justification", "description": ("Why this call should go ahead, with the evidence for it. " "A person reads this and nothing else before deciding, so " "name what you looked at, not that you looked."), } schema["properties"] = props - schema["required"] = list(schema.get("required") or []) + [WHY] + schema["required"] = list(schema.get("required") or []) + [JUSTIFICATION] tool.args_schema = schema if tool.coroutine: inner = tool.coroutine async def run(*args, **kwargs): - kwargs.pop(WHY, None) + kwargs.pop(JUSTIFICATION, None) return await inner(*args, **kwargs) tool.coroutine = run @@ -399,7 +403,7 @@ async def run(*args, **kwargs): inner_fn = tool.func def run_sync(*args, **kwargs): - kwargs.pop(WHY, None) + kwargs.pop(JUSTIFICATION, None) return inner_fn(*args, **kwargs) tool.func = run_sync diff --git a/charter/workflows/loop.py b/charter/workflows/loop.py index 71a459b..3577a3d 100644 --- a/charter/workflows/loop.py +++ b/charter/workflows/loop.py @@ -786,15 +786,17 @@ def _justify(self, action: dict) -> str: """ name = action.get("name", "a tool") args = dict(action.get("args") or {}) - # Charter's own field, asked of every gated tool. It leads, because it is - # the agent's case rather than a restatement of the call. - why = str(args.pop("why", "")).strip() + # Charter asks every gated MCP tool for this, so it is the agent's own + # account and stands alone. + if stated := str(args.pop("justification", "")).strip(): + return stated + + # A harness tool gated by `gate.tools` never passed through that, so there + # is nothing but the call to describe. detail = ", ".join(f"{k}={v!r}" for k, v in args.items()) line = f"{self.cfg.name} wants to call {name}" if detail: line += f" with {detail}" - if why: - line = f"{why}\n\n{line}" described = (action.get("description") or "").strip() if described and not described.lower().startswith("tool execution requires"): diff --git a/examples/refund-triage/v1.yaml b/examples/refund-triage/v1.yaml index d320d86..a835650 100644 --- a/examples/refund-triage/v1.yaml +++ b/examples/refund-triage/v1.yaml @@ -47,6 +47,8 @@ mcp: - tool: create_refund approval: always on_failure: fail + # The model must state its case, and the approver reads it. + justify: true response_format: resolution: diff --git a/tests/test_agent_config.py b/tests/test_agent_config.py index 2351e0c..b464041 100644 --- a/tests/test_agent_config.py +++ b/tests/test_agent_config.py @@ -322,8 +322,8 @@ def _tool(self, coroutine=None, func=None, schema=None): def test_the_model_is_asked_for_it(self): from charter.mcp.client import _explained tool = _explained(self._tool(func=lambda **kw: kw)) - assert "why" in tool.args_schema["properties"] - assert "why" in tool.args_schema["required"] + assert "justification" in tool.args_schema["properties"] + assert "justification" in tool.args_schema["required"] def test_the_server_never_sees_it(self): """It is not a parameter the tool declared, so passing it through would @@ -331,10 +331,27 @@ def test_the_server_never_sees_it(self): from charter.mcp.client import _explained seen = {} tool = _explained(self._tool(func=lambda **kw: seen.update(kw))) - tool.func(charge_id="ch_1", why="charged twice") + tool.func(charge_id="ch_1", justification="charged twice") assert seen == {"charge_id": "ch_1"} def test_it_is_added_once(self): from charter.mcp.client import _explained tool = _explained(_explained(self._tool(func=lambda **kw: kw))) - assert tool.args_schema["required"].count("why") == 1 + assert tool.args_schema["required"].count("justification") == 1 + + def test_a_tool_can_decline_to_be_asked(self): + """`justify: false` for a call whose arguments already say everything.""" + from charter.config.agent import AgentConfig + raw = load() + raw["mcp"][0]["tools"] = [{"tool": "create_refund", "approval": "always", + "justify": False}] + cfg = AgentConfig.model_validate(raw) + spec = next(t for t in cfg.mcp[0].tools if t.tool == "create_refund") + assert spec.justify is False + + def test_it_is_asked_for_by_default(self): + from charter.config.agent import AgentConfig + raw = load() + raw["mcp"][0]["tools"] = [{"tool": "create_refund", "approval": "always"}] + cfg = AgentConfig.model_validate(raw) + assert next(t for t in cfg.mcp[0].tools if t.tool == "create_refund").justify diff --git a/tests/test_loop.py b/tests/test_loop.py index fa99471..083f158 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -904,13 +904,20 @@ def test_a_policy_sourced_ceiling_parks_as_an_integer(): assert isinstance(out.delay_seconds, int) -def test_the_agents_why_leads_the_justification(): - """`justification` is the only field a notification carries, so the agent's - case for the call goes first and the call itself follows.""" +def test_the_agents_own_words_are_the_justification(): + """`justification` is the only field a notification carries, and Charter asks + every gated MCP tool for it, so it stands alone rather than being wrapped in a + sentence describing the call.""" _, loop = loop_for() text = loop._justify({"name": "support__create_refund", "args": {"charge_id": "ch_1", "amount_usd": 48.0, - "why": "charged twice for order #4417"}}) - assert text.startswith("charged twice for order #4417") - assert "support__create_refund" in text - assert "why=" not in text, "why leads it rather than being listed as an argument" + "justification": "charged twice for order #4417"}}) + assert text == "charged twice for order #4417" + + +def test_a_harness_tool_gate_still_describes_the_call(): + """`gate.tools` gates tools that never passed through the MCP wrapper, so + nothing asked the agent for a justification and the call is all there is.""" + _, loop = loop_for() + text = loop._justify({"name": "write_file", "args": {"path": "/tmp/x"}}) + assert "write_file" in text and "/tmp/x" in text From c7a468c0614bf7eea9859c0ff466f151d5426e75 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 23:50:02 -0400 Subject: [PATCH 26/49] Say it plainly in the examples README The two policy paragraphs were written rather than instructional: "the ceiling is what holds when it doesn't", "says something about the agent rather than the task". They now say what the file does and what to run to see it, with the describe output the reader will get. --- examples/README.md | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/examples/README.md b/examples/README.md index c40b89b..ebfdd42 100644 --- a/examples/README.md +++ b/examples/README.md @@ -63,28 +63,27 @@ Approve that one and the task finishes: $48.00 each. Refunded $24.00 for our duplicate charge; the other $48.00 charge was authorized by the customer and remains. -The agent will keep revising while you keep giving it reasons, so `runtime.yaml` -caps `support__create_refund` at three calls per task. The objective asks it to -revise rather than repeat; the ceiling is what holds when it doesn't. +The `runtime.yaml` we applied caps `support__create_refund` at three calls per +task, so the agent gets three tries to land on the right amount. After that the +call is refused and it has to finish without a refund. -Turning refunds down often enough says something about the agent rather than the -task, and `lifecycle.yaml` acts on that: four rejections across the last three -runs and it pauses itself. +The `lifecycle.yaml` we applied pauses the agent after four rejections across the +last three runs. Reject that many times, then look at it: - AGENT INSTANCE VER STATUS ACTIVITY - refund-triage 5054d8e3 v1 paused active + charter describe refund-triage --instance - stopped - charter resume refund-triage --instance 5054d8e3 - charter audit refund-triage --instance 5054d8e3 + refund-triage + version v1 + status paused + activity active -Further runs are refused until `charter resume refund-triage --instance `. -One run can propose at most three refunds, so this can only be a pattern across -runs, which is the difference between the two policy files: `runtime.yaml` bounds -one task, `lifecycle.yaml` reacts to several. +Further runs are refused until you resume it: -`charter status ` is where you read the outcome, and `charter ui` does -all of this in a browser, across every agent at once. + charter resume refund-triage --instance + +One run can only propose three refunds, so this always takes more than one run. +That is the split between the two files: `runtime.yaml` bounds a task, +`lifecycle.yaml` watches across them. ## Rolling a version back From 1af9ce98d619ef3ad96811c48a931934d922b43a Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 23:52:56 -0400 Subject: [PATCH 27/49] Drop the closing explanation from the refund section --- examples/README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/examples/README.md b/examples/README.md index ebfdd42..ff6ce3b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -81,10 +81,6 @@ Further runs are refused until you resume it: charter resume refund-triage --instance -One run can only propose three refunds, so this always takes more than one run. -That is the split between the two files: `runtime.yaml` bounds a task, -`lifecycle.yaml` watches across them. - ## Rolling a version back `ticket-summarizer` has no gated tools, so nothing stops for a human. It has two From f966ea0cc7d273b2b6bd9684a98735de48879d8a Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Tue, 8 Sep 2026 23:58:09 -0400 Subject: [PATCH 28/49] Say when a lifecycle rule is evaluated A rejection that crosses the threshold mid-run leaves the agent active until that run finishes, which reads as the rule not working. --- examples/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/README.md b/examples/README.md index ff6ce3b..7ae3721 100644 --- a/examples/README.md +++ b/examples/README.md @@ -77,6 +77,10 @@ last three runs. Reject that many times, then look at it: status paused activity active +If the fourth rejection lands mid-run, the agent stays active until that run +finishes. Lifecycle rules are evaluated between runs, not during one, so they +decide whether the next run starts rather than stopping the one in flight. + Further runs are refused until you resume it: charter resume refund-triage --instance From d5249449c92d1e09d23b90f7a55f2fbd9c17dd80 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Wed, 9 Sep 2026 00:01:11 -0400 Subject: [PATCH 29/49] List the tickets, and say how to reach four rejections The README named T-1041 and nothing else, so there was no way to know the other three existed or which of them propose a refund. Reaching the pause needs a second run, which the reader had no way to set up. --- examples/README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index 7ae3721..ed39d86 100644 --- a/examples/README.md +++ b/examples/README.md @@ -31,6 +31,13 @@ In a third, give it a ticket: charter run refund-triage --instance --ticket_id T-1041 +There are four tickets. Two of them are refunds, two are not: + + T-1041 charged twice for one order proposes a refund + T-1042 package never arrived proposes a refund + T-1043 wrong size, wants an exchange no refund, finishes on its own + T-1044 cancel a subscription no refund, finishes on its own + The agent reads the ticket, looks up the charge, and asks to refund it. Nothing holds your terminal open while it waits: @@ -68,7 +75,8 @@ task, so the agent gets three tries to land on the right amount. After that the call is refused and it has to finish without a refund. The `lifecycle.yaml` we applied pauses the agent after four rejections across the -last three runs. Reject that many times, then look at it: +last three runs. Three per run is the cap, so run it again on T-1042, or on +T-1041 a second time, and keep rejecting. Then look at it: charter describe refund-triage --instance From 23583a4cf31257b7306e3b00da5f5be9b8ac52a2 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Wed, 9 Sep 2026 00:03:19 -0400 Subject: [PATCH 30/49] Stop promising what the agent will decide The ticket list claimed T-1043 and T-1044 finish without a refund. T-1043 does, which I ran; T-1044 was a guess. The model reads the ticket and the refund policy and decides, so the list now says what each ticket is and leaves the outcome to the run. --- examples/README.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/examples/README.md b/examples/README.md index ed39d86..75a30d1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -31,12 +31,16 @@ In a third, give it a ticket: charter run refund-triage --instance --ticket_id T-1041 -There are four tickets. Two of them are refunds, two are not: +There are four tickets: - T-1041 charged twice for one order proposes a refund - T-1042 package never arrived proposes a refund - T-1043 wrong size, wants an exchange no refund, finishes on its own - T-1044 cancel a subscription no refund, finishes on its own + T-1041 charged twice for one order + T-1042 package never arrived + T-1043 wrong size, wants an exchange + T-1044 cancel a subscription + +The first two are the ones a refund is for. On the other two the agent has the +refund policy in `v1/skills/` to go on, and decides for itself, so it may finish +without proposing anything and never open a gate. The agent reads the ticket, looks up the charge, and asks to refund it. Nothing holds your terminal open while it waits: From e30778a1fd300417a6eed0804b119772d91dfa7d Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Wed, 9 Sep 2026 00:04:31 -0400 Subject: [PATCH 31/49] Just give the ticket ids Nobody reading this cares what the fictional customers wanted. They need an input to pass. --- examples/README.md | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/examples/README.md b/examples/README.md index 75a30d1..4975cfb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -31,21 +31,7 @@ In a third, give it a ticket: charter run refund-triage --instance --ticket_id T-1041 -There are four tickets: - - T-1041 charged twice for one order - T-1042 package never arrived - T-1043 wrong size, wants an exchange - T-1044 cancel a subscription - -The first two are the ones a refund is for. On the other two the agent has the -refund policy in `v1/skills/` to go on, and decides for itself, so it may finish -without proposing anything and never open a gate. - -The agent reads the ticket, looks up the charge, and asks to refund it. Nothing -holds your terminal open while it waits: - - charter pending refund-triage --instance +Tickets are T-1041, T-1042, T-1043 and T-1044. That prints the call it wants to make and the two commands that answer it. Both take a reason, and the reason is not paperwork: it is handed to the agent. From 735c78db5ba05b1443244d37c9a3b23800c5c192 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Wed, 9 Sep 2026 00:12:55 -0400 Subject: [PATCH 32/49] Version the prompt, which is what people actually roll back v2 differed from v1 by model and a schedule. Neither is what versioning is for in practice: production teams change prompts 5 to 15 times a week, and prompt regressions are the leading cause of agent quality drift. Every tool in the space is built around prompt versions and rollback. v2 now differs only in its objective: it opens each ticket and the charge behind it rather than working from subject lines. Better summaries, more model calls, which is why a cost rule rolls it back. Dropping the schedule also removes an ambiguity: with v2 changing two things at once, a rollback for cost silently turned scheduling off as well. The two tests that read model and schedule off the example now build what they assert. --- examples/README.md | 8 ++++---- examples/ticket-summarizer/v2.yaml | 6 +++--- tests/test_compile.py | 21 +++++++++++++++++++-- tests/test_loader.py | 5 +++-- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/examples/README.md b/examples/README.md index 4975cfb..f8fdde3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -85,10 +85,10 @@ Further runs are refused until you resume it: ## Rolling a version back -`ticket-summarizer` has no gated tools, so nothing stops for a human. It has two -versions: v1 uses a cheaper model and runs when you ask, v2 uses a better one and -runs every fifteen minutes on its own. `lifecycle.yaml` says what to do if v2 is -not worth it: +`ticket-summarizer` has no gated tools, so nothing stops for a human. v2 changes +the prompt: it opens every ticket and the charge behind it rather than working +from subject lines. Better summaries, more model calls. `lifecycle.yaml` says +what to do when that costs too much: - when: { metric: cost, threshold: 0.05 } then: { set_version: { target: 1 } } diff --git a/examples/ticket-summarizer/v2.yaml b/examples/ticket-summarizer/v2.yaml index e9b2fb0..5e5b8fe 100644 --- a/examples/ticket-summarizer/v2.yaml +++ b/examples/ticket-summarizer/v2.yaml @@ -5,14 +5,14 @@ name: ticket-summarizer version: 2 description: Summarizes what changed across open tickets since the last look. -model: claude-sonnet-5 +model: claude-haiku-4-5 objective: | Review the open support tickets and summarize what needs a human's attention. Lead with anything that has been waiting longest. -schedule: - every: 15m + For each one, open the ticket and the charge behind it, and say what the + customer is owed if anything. Do not summarize from the subject line alone. mcp: - name: support diff --git a/tests/test_compile.py b/tests/test_compile.py index ca08188..9e1e9f7 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -139,8 +139,25 @@ def test_entry_operation_gets_the_same_timeout_as_every_other_round(): assert compiled.workflow_config.invoke_timeout_seconds == 40 * 60 -def test_schedule_becomes_repeat_and_triggerable(): - c = compile_agent(load_agent(EXAMPLES / "ticket-summarizer"), 2) +def test_schedule_becomes_repeat_and_triggerable(tmp_path): + """Written here rather than read from an example: a schedule the examples + happen to carry is one they can stop carrying, and this asserts the + translation rather than the example.""" + agent = tmp_path / "scheduled" + agent.mkdir() + (agent / "v1.yaml").write_text(""" +apiVersion: charter/v1 +kind: AgentConfig +name: scheduled +version: 1 +model: claude-haiku-4-5 +objective: Look at the thing. +schedule: + every: 15m +response_format: + summary: { type: string, description: What happened. } +""".lstrip()) + c = compile_agent(load_agent(agent)) assert c.workflow_config.repeat_every_seconds == 900 assert c.workflow_config.triggerable is True diff --git a/tests/test_loader.py b/tests/test_loader.py index 7c60324..ebdc655 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -35,9 +35,10 @@ def test_examples_load(): def test_agent_bundle_holds_every_version(project): bundle = load_agent(project / "ticket-summarizer") - assert bundle.versions[1].model == "claude-haiku-4-5" - assert bundle.versions[2].model == "claude-sonnet-5" + assert set(bundle.versions) == {1, 2} assert bundle.latest.version == 2 + # What a version is for: the same agent told to do the job differently. + assert bundle.versions[1].objective != bundle.versions[2].objective def test_contrasting_agent_is_coalesce(): From 3afb48839148d51ffd2f07ee530671342660ac00 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Wed, 9 Sep 2026 00:13:30 -0400 Subject: [PATCH 33/49] Say what the rollback is for, not what the prompt does The reader is trying a new version of a prompt and wants it rolled back if it costs too much. What the prompt was changed to is the example's business. --- examples/README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/README.md b/examples/README.md index f8fdde3..3b5e453 100644 --- a/examples/README.md +++ b/examples/README.md @@ -85,10 +85,8 @@ Further runs are refused until you resume it: ## Rolling a version back -`ticket-summarizer` has no gated tools, so nothing stops for a human. v2 changes -the prompt: it opens every ticket and the charge behind it rather than working -from subject lines. Better summaries, more model calls. `lifecycle.yaml` says -what to do when that costs too much: +`ticket-summarizer` has no gated tools, so nothing stops for a human. v2 is a new +version of its prompt. If it turns out to cost too much, roll back to v1: - when: { metric: cost, threshold: 0.05 } then: { set_version: { target: 1 } } From bddedf04b0205689a215e450c2d753afb5b7db99 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Wed, 9 Sep 2026 00:14:07 -0400 Subject: [PATCH 34/49] Drop the gating aside from the rollback section --- examples/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/README.md b/examples/README.md index 3b5e453..10b0895 100644 --- a/examples/README.md +++ b/examples/README.md @@ -85,8 +85,8 @@ Further runs are refused until you resume it: ## Rolling a version back -`ticket-summarizer` has no gated tools, so nothing stops for a human. v2 is a new -version of its prompt. If it turns out to cost too much, roll back to v1: +v2 is a new version of `ticket-summarizer`'s prompt. If it turns out to cost too +much, roll back to v1: - when: { metric: cost, threshold: 0.05 } then: { set_version: { target: 1 } } From 73e6e8c7a4263998e6518c489cac68d27147f297 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Wed, 9 Sep 2026 00:57:10 -0400 Subject: [PATCH 35/49] Cap how many times one task may ask a person `max_calls` counts runs, and a rejected gated call never runs, so nothing bounded how often an agent could put the same decision in front of someone. A task could propose a refund all afternoon. `max_proposals` sits on the same tool_call_limits entry and bounds proposals. Past it the agent is told and carries on, the way a spent call cap works, and the call does not run because nobody approved it. Not routed through `on_reject`: nobody refused. Failing a task because we declined to ask would punish the agent for a limit the operator set. The example now allows one refund to go through and four proposals, which leaves room to correct it from a rejection and then stop asking. --- charter/config/runtime.py | 8 +++++- charter/workflows/loop.py | 32 ++++++++++++++++++++++++ examples/README.md | 7 +++--- examples/refund-triage/lifecycle.yaml | 2 +- examples/refund-triage/runtime.yaml | 5 ++-- tests/test_compile.py | 2 +- tests/test_loop.py | 35 +++++++++++++++++++++++++++ 7 files changed, 82 insertions(+), 9 deletions(-) diff --git a/charter/config/runtime.py b/charter/config/runtime.py index ec60380..0734eb9 100644 --- a/charter/config/runtime.py +++ b/charter/config/runtime.py @@ -20,7 +20,13 @@ class ToolCallLimit(Base): # Namespaced .; checked against the agent config by the loader. tool: str - max_calls: int = Field(gt=0) + max_calls: int = Field(gt=0, description=( + "Times this tool may run in one task. A gated call that is rejected never " + "runs, so it does not count against this.")) + max_proposals: int | None = Field(default=None, gt=0, description=( + "Times this tool may be *proposed* in one task, for a gated tool. Past it " + "the agent is told and carries on, so a task cannot ask a person the same " + "thing all afternoon. Unset is unlimited.")) class CapabilityLimit(Base): diff --git a/charter/workflows/loop.py b/charter/workflows/loop.py index 3577a3d..eb0bf94 100644 --- a/charter/workflows/loop.py +++ b/charter/workflows/loop.py @@ -56,6 +56,8 @@ K_LLM_CALLS = "_llm_calls" K_GATES = "_gates" K_GATED_TOOL = "_gated_tool" +K_ASKS = "_asks" # gates raised per tool, this task +K_SPENT_ASKS = "_spent_asks" K_WAITED = "_waited" # total seconds slept, for the record K_WAITED_FOR = "_waited_for" K_SECONDS = "_seconds" # working time, excluding waits for a human @@ -420,6 +422,15 @@ async def entry(self, ctx): ctx, f"{refused} was not approved ({because}) and this agent is " f"declared on_reject: fail") decision = reject(ctx.approval_reason or "no reason given") + elif verdict == "asked_enough": + # Not routed through `on_reject`: nobody refused this. The ceiling is + # ours, and failing a task because we declined to ask would punish the + # agent for a limit the operator set. + tool = ctx.context.pop(K_SPENT_ASKS, "that tool") + cap = self._proposal_cap(tool) + decision = reject( + f"Proposal limit reached for {tool!r} (max {cap}). Nobody was asked " + f"this time. Do not propose it again; finish with what you have.") elif verdict == "answer": decision = respond(_answer_text(ctx.input_answer)) elif verdict == "waited": @@ -661,6 +672,20 @@ def resume(verdict: str): if action.get("name") == WAIT_TOOL: return self._sleep(ctx, action, c) + asked = dict(c.get(K_ASKS) or {}) + cap = self._proposal_cap(tool) + if cap and asked.get(tool, 0) >= cap: + # Spent, so the agent is told rather than a person being asked again. + # The call does not run: it was gated, and nobody approved it. + log.info("proposal cap: agent=%s tool=%s cap=%d", self.cfg.name, tool, cap) + c[K_SPENT_ASKS] = tool + return Next(ENTRY_OPERATION, + context=task_context(ctx, {**c, K_DECISION: "asked_enough", + K_GATED_TOOL: tool}), + timeout=self._operation_timeout()) + asked[tool] = asked.get(tool, 0) + 1 + c[K_ASKS] = asked + log.info("gate: agent=%s tool=%s", self.cfg.name, action.get("name", "?")) return AwaitApproval( on_approve=resume("approve"), @@ -743,6 +768,13 @@ def _gate_timeout(self, tool: str) -> int: return spec.approval_timeout_seconds return self.runtime.authority.approval_timeout_seconds + def _proposal_cap(self, tool: str) -> int | None: + """How many times this tool may be proposed in one task, if capped.""" + for limit in self.runtime.per_run.tool_call_limits: + if limit.tool == tool: + return limit.max_proposals + return None + def _on_reject(self, tool: str) -> str: """What a refusal of this tool means, per tool where it says. diff --git a/examples/README.md b/examples/README.md index 10b0895..e8c1c12 100644 --- a/examples/README.md +++ b/examples/README.md @@ -61,12 +61,11 @@ Approve that one and the task finishes: other $48.00 charge was authorized by the customer and remains. The `runtime.yaml` we applied caps `support__create_refund` at three calls per -task, so the agent gets three tries to land on the right amount. After that the -call is refused and it has to finish without a refund. +task. A rejected proposal never runs, so it does not count: what the cap bounds +is how many refunds actually go through. The `lifecycle.yaml` we applied pauses the agent after four rejections across the -last three runs. Three per run is the cap, so run it again on T-1042, or on -T-1041 a second time, and keep rejecting. Then look at it: +last three runs. Keep rejecting, on this ticket or another, until it stops: charter describe refund-triage --instance diff --git a/examples/refund-triage/lifecycle.yaml b/examples/refund-triage/lifecycle.yaml index 0a35474..a95bb6f 100644 --- a/examples/refund-triage/lifecycle.yaml +++ b/examples/refund-triage/lifecycle.yaml @@ -5,6 +5,6 @@ agent: refund-triage rules: # If 4 refund attempts are rejected in the last 3 runs, stay paused until someone - # runs `charter resume`. One run may propose at most 3 times (see runtime policy), so this spans more than one run + # runs `charter resume`. - when: { metric: approval_rejections, threshold: 4 } then: { pause: { window: 3 } } diff --git a/examples/refund-triage/runtime.yaml b/examples/refund-triage/runtime.yaml index d6ffd28..f4e076c 100644 --- a/examples/refund-triage/runtime.yaml +++ b/examples/refund-triage/runtime.yaml @@ -8,9 +8,10 @@ per_run: max_cost_usd: 0.30 max_llm_calls: 40 tool_call_limits: - # Let the agent get 3 chances to get the refund amount right, we will give it feedback if we reject + # This tool may be proposed by the agent 4 times per run but can only be run once - tool: support__create_refund - max_calls: 3 + max_calls: 1 + max_proposals: 4 - tool: support__get_charge max_calls: 5 - tool: support__get_ticket diff --git a/tests/test_compile.py b/tests/test_compile.py index 9e1e9f7..d08be73 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -57,7 +57,7 @@ def test_runtime_policy(): assert p.max_tokens_per_call == 1024 assert p.max_call_seconds == 60 assert {l.tool: l.max_calls for l in p.tool_call_limits} == { - "support__create_refund": 3, + "support__create_refund": 1, "support__get_charge": 5, "support__get_ticket": 10, } diff --git a/tests/test_loop.py b/tests/test_loop.py index 083f158..0880d0f 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -921,3 +921,38 @@ def test_a_harness_tool_gate_still_describes_the_call(): _, loop = loop_for() text = loop._justify({"name": "write_file", "args": {"path": "/tmp/x"}}) assert "write_file" in text and "/tmp/x" in text + + +class TestProposalCap: + """`max_proposals` bounds how often one task may ask a person about a tool. + + `max_calls` counts runs, and a rejected gated call never runs, so without this + an agent can put the same decision in front of someone all afternoon. + """ + + def _loop(self, cap): + cfg, loop = loop_for() + loop.runtime.per_run.tool_call_limits = [ + type(loop.runtime.per_run.tool_call_limits[0])( + tool="support__create_refund", max_calls=1, max_proposals=cap)] + return loop + + def test_it_gates_until_the_cap(self): + loop = self._loop(2) + ctx = FakeCtx(context={}) + out = loop._gate(ctx, {"name": "support__create_refund", "args": {}}) + assert isinstance(out, AwaitApproval) + assert ctx.context["_asks"]["support__create_refund"] == 1 + + def test_past_the_cap_nobody_is_asked(self): + loop = self._loop(1) + ctx = FakeCtx(context={"_asks": {"support__create_refund": 1}}) + out = loop._gate(ctx, {"name": "support__create_refund", "args": {}}) + assert not isinstance(out, AwaitApproval), "a person was asked past the cap" + assert out.context["_decision"] == "asked_enough" + + def test_no_cap_means_no_ceiling(self): + loop = self._loop(None) + ctx = FakeCtx(context={"_asks": {"support__create_refund": 99}}) + out = loop._gate(ctx, {"name": "support__create_refund", "args": {}}) + assert isinstance(out, AwaitApproval) From 9cfab86e49a02ab16f701ec3936ead28f430b93a Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Wed, 9 Sep 2026 01:03:34 -0400 Subject: [PATCH 36/49] Cap proposals at three so the pause always spans runs --- examples/README.md | 6 +++--- examples/refund-triage/runtime.yaml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/README.md b/examples/README.md index e8c1c12..1f79427 100644 --- a/examples/README.md +++ b/examples/README.md @@ -60,9 +60,9 @@ Approve that one and the task finishes: $48.00 each. Refunded $24.00 for our duplicate charge; the other $48.00 charge was authorized by the customer and remains. -The `runtime.yaml` we applied caps `support__create_refund` at three calls per -task. A rejected proposal never runs, so it does not count: what the cap bounds -is how many refunds actually go through. +The `runtime.yaml` we applied lets one refund go through per task, and lets the +agent propose three. A rejected proposal never runs, so it costs nothing against +the first number. The second is what stops it asking you a fourth time. The `lifecycle.yaml` we applied pauses the agent after four rejections across the last three runs. Keep rejecting, on this ticket or another, until it stops: diff --git a/examples/refund-triage/runtime.yaml b/examples/refund-triage/runtime.yaml index f4e076c..d40d695 100644 --- a/examples/refund-triage/runtime.yaml +++ b/examples/refund-triage/runtime.yaml @@ -8,10 +8,10 @@ per_run: max_cost_usd: 0.30 max_llm_calls: 40 tool_call_limits: - # This tool may be proposed by the agent 4 times per run but can only be run once + # This tool may be proposed by the agent 3 times per run but can only be run once - tool: support__create_refund max_calls: 1 - max_proposals: 4 + max_proposals: 3 - tool: support__get_charge max_calls: 5 - tool: support__get_ticket From 8b155d5e2892e12b2c750b9a1ed0ab41c8d902a5 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Wed, 9 Sep 2026 01:14:09 -0400 Subject: [PATCH 37/49] Stop the console showing the justification twice It is its own field on the approval, and it was also left in the metadata arguments, so anything rendering both printed the agent's sentence once as the justification and again inside args. The CLI hid it at render time; the console did not. Verified against the console, which now carries what the CLI does: the approval id, the justification, the tool and its arguments, when it opened and when it times out. The examples README says it is there. --- charter/workflows/loop.py | 6 +++++- examples/README.md | 3 +++ tests/test_loop.py | 11 +++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/charter/workflows/loop.py b/charter/workflows/loop.py index eb0bf94..ad49b0a 100644 --- a/charter/workflows/loop.py +++ b/charter/workflows/loop.py @@ -692,7 +692,11 @@ def resume(verdict: str): on_reject=resume("reject"), timeout=self._gate_timeout(tool), justification=self._justify(action), - metadata={"tool": action.get("name", ""), "args": action.get("args", {})}, + # Without the justification: it is the field above, and anything + # rendering both showed the agent's sentence twice. + metadata={"tool": action.get("name", ""), + "args": {k: v for k, v in (action.get("args") or {}).items() + if k != "justification"}}, ) def _max_wait(self, ctx) -> int: diff --git a/examples/README.md b/examples/README.md index 1f79427..cbe5b87 100644 --- a/examples/README.md +++ b/examples/README.md @@ -39,6 +39,9 @@ take a reason, and the reason is not paperwork: it is handed to the agent. charter approve --agent refund-triage --instance --reason '...' charter reject --agent refund-triage --instance --reason '...' +`charter ui` answers them in a browser instead, with the same fields and a box +for your reason. + Approve, and the refund goes through and the task finishes with what it did. Reject with a reason that says what was wrong, and the agent works from it: diff --git a/tests/test_loop.py b/tests/test_loop.py index 0880d0f..5f5a374 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -956,3 +956,14 @@ def test_no_cap_means_no_ceiling(self): ctx = FakeCtx(context={"_asks": {"support__create_refund": 99}}) out = loop._gate(ctx, {"name": "support__create_refund", "args": {}}) assert isinstance(out, AwaitApproval) + + +def test_the_justification_is_not_repeated_in_the_metadata(): + """It is its own field. Anything rendering both, like the console, showed the + agent's sentence once as the justification and again inside the arguments.""" + _, loop = loop_for() + out = loop._gate(FakeCtx(context={}), + {"name": "support__create_refund", + "args": {"charge_id": "ch_1", "justification": "charged twice"}}) + assert out.metadata["args"] == {"charge_id": "ch_1"} + assert out.justification == "charged twice" From 8fdc646a93f4d7b52229a956aca15fb9ca4ed267 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Wed, 9 Sep 2026 01:19:04 -0400 Subject: [PATCH 38/49] Put the console where the first decision happens, and restore charter pending `charter pending` had been lost in an edit, so "that prints the call it wants to make" referred to nothing and there was no way to see a gate or get an approval id. The console is introduced right after the first run, which is Prefect's placement for the same reason: the task appears, then the gate does, on a refresh. It is also more than approvals, so the line says what else it holds. --- examples/README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/examples/README.md b/examples/README.md index cbe5b87..d6cd704 100644 --- a/examples/README.md +++ b/examples/README.md @@ -33,15 +33,24 @@ In a third, give it a ticket: Tickets are T-1041, T-1042, T-1043 and T-1044. +Nothing holds your terminal open while it runs. Open the console to watch it: + + charter ui + +The task shows up, then a gate when the agent proposes a refund. It refreshes on +its own, and it is where you answer gates, suspend an agent, or read its run +history and metrics. + +From a terminal instead: + + charter pending refund-triage --instance + That prints the call it wants to make and the two commands that answer it. Both take a reason, and the reason is not paperwork: it is handed to the agent. charter approve --agent refund-triage --instance --reason '...' charter reject --agent refund-triage --instance --reason '...' -`charter ui` answers them in a browser instead, with the same fields and a box -for your reason. - Approve, and the refund goes through and the task finishes with what it did. Reject with a reason that says what was wrong, and the agent works from it: From 2afc52ef84e3d55ae45d6889e2937cc30ef85940 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Wed, 9 Sep 2026 01:19:43 -0400 Subject: [PATCH 39/49] Open the console once and refer back to it Introduced right after the agent exists, then pointed at when the task appears, when a gate opens, and when the run picks back up. One instruction to open it rather than three, and each stage says what it shows. --- examples/README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/examples/README.md b/examples/README.md index d6cd704..450aab2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -27,19 +27,18 @@ In a second terminal, from this directory, bring up an agent and a worker: charter apply . charter worker . +`charter ui` opens the console, where the agent appears as soon as it exists. +Leave it open: everything below shows up there as it happens. + In a third, give it a ticket: charter run refund-triage --instance --ticket_id T-1041 Tickets are T-1041, T-1042, T-1043 and T-1044. -Nothing holds your terminal open while it runs. Open the console to watch it: - - charter ui - -The task shows up, then a gate when the agent proposes a refund. It refreshes on -its own, and it is where you answer gates, suspend an agent, or read its run -history and metrics. +Nothing holds your terminal open while it runs. In the console the task appears, +then a gate when the agent proposes a refund. Answer it there, with a box for +your reason. From a terminal instead: @@ -51,7 +50,8 @@ take a reason, and the reason is not paperwork: it is handed to the agent. charter approve --agent refund-triage --instance --reason '...' charter reject --agent refund-triage --instance --reason '...' -Approve, and the refund goes through and the task finishes with what it did. +Approve, and the refund goes through and the task finishes with what it did. The +console shows it pick back up, and the audit there records who decided and why. Reject with a reason that says what was wrong, and the agent works from it: From 164814e026224a7e7fe5174a2f9bc6e673d83de1 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Wed, 9 Sep 2026 01:29:14 -0400 Subject: [PATCH 40/49] Read the gate's justification from the approval, not the arguments Keeping it out of metadata.args stopped the console printing it twice, and it also removed what the CLI was rendering its row from, so the gate lost the one line that says why. The console was unaffected: it reads the field. Verified on BoundFlow 0.7.0, both surfaces, with a test that the row survives arguments that do not carry it. --- charter/cli.py | 16 +++++++--------- tests/test_cli.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/charter/cli.py b/charter/cli.py index 0fe8af9..c9f2aa0 100644 --- a/charter/cli.py +++ b/charter/cli.py @@ -1291,15 +1291,13 @@ def _gate_fields(g, kind: str) -> list[tuple[str, object]]: or getattr(g, "input_id", ""))] if tool := meta.pop("tool", ""): rows.append(("tool", tool)) - args = meta.pop("args", None) - if isinstance(args, dict): - args = dict(args) - if stated := str(args.pop("justification", "")).strip(): - rows.append(("justification", stated)) - if args: - rows.append(("args", ", ".join(f"{k}={v!r}" for k, v in args.items()))) - elif args: - rows.append(("args", args)) + # From the approval's own field, not from the arguments: the agent writes it + # there and Charter keeps it out of the arguments so nothing renders it twice. + if stated := str(getattr(g, "justification", "") or "").strip(): + rows.append(("justification", stated)) + if args := meta.pop("args", None): + rows.append(("args", ", ".join(f"{k}={v!r}" for k, v in args.items()) + if isinstance(args, dict) else args)) rows += sorted(meta.items()) if opened := getattr(g, "opened_at", None): rows.append(("opened", _stamp(opened))) diff --git a/tests/test_cli.py b/tests/test_cli.py index a22a115..373c298 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -765,3 +765,21 @@ def test_a_deleted_instance_is_not_offered_to_pick_from(cp): assert "has 1 instance" in out, out assert "deleted" not in out + + +def test_the_gate_shows_the_justification_from_its_own_field(cp): + """Charter keeps it out of the arguments so nothing renders it twice, so the + row has to come from the approval's `justification`, not from `metadata.args`. + """ + cp.workflows = [workflow( + "refund-triage", lifecycle_state=LifecycleState.AWAITING_APPROVAL, + pending=PendingApproval( + approval_id="apr_1", justification="charged twice for order #4417", + metadata={"tool": "support__create_refund", + "args": {"charge_id": "ch_1"}}, + opened_at=NOW, timeout_at=None))] + + out = invoke("pending", "refund-triage", "--instance", "wf_refun").output + + assert "charged twice for order #4417" in out + assert "ch_1" in out From 5fed8a67fe5089f908bf826eff3d95854b23436b Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Wed, 9 Sep 2026 01:30:06 -0400 Subject: [PATCH 41/49] Pin the floor to the BoundFlow anyone tests CI installs the SDK from PyPI and runs it against `:latest`, so 0.7.0 with 0.7.0 is the only combination that gets exercised. The floor said 0.6.0, which allowed a pairing nobody checks. --- pyproject.toml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2cca154..5b3f201 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,10 +23,9 @@ classifiers = [ ] dependencies = [ - # 0.6.0 is the floor: the governor Charter runs agents under (run_governed, - # agent_governor), the shared renderer (boundflow.cli.output), and the console - # labels `charter ui` sets all arrived in it. - "boundflow>=0.6.0", + # The floor is what CI tests: the SDK from PyPI against `:latest` in + # deploy/local.compose.yml. An older SDK may work and nobody checks. + "boundflow>=0.7.0", # The harness, and required rather than optional. Charter is the # use-an-existing-harness product: an install that can't run deepagents isn't a # smaller Charter, it's a Charter that does nothing. BoundFlow keeps these From 62c78b3d88208427bb9ac63f6b784b16d99d8415 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Wed, 9 Sep 2026 01:36:24 -0400 Subject: [PATCH 42/49] Show what is inside the custom policy `custom` is Charter's half of the runtime policy: BoundFlow carries it and reads none of it, so it arrived as one opaque dict and printed as one. Eight limits an operator looks for by name were behind it. Two rendering bugs it was hiding: capability limits name a capability rather than a tool, so every one read `None=30`, and a tool limit's proposal ceiling was dropped. --- charter/cli.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/charter/cli.py b/charter/cli.py index c9f2aa0..4f654f7 100644 --- a/charter/cli.py +++ b/charter/cli.py @@ -1071,8 +1071,7 @@ async def go(): if policy: # Comes back as protobuf-JSON camelCase; show it the way it was # written, so what you read here matches runtime.yaml verbatim. - ui.kv([(_snake(k), _fmt(v)) for k, v in sorted(policy.items()) - if v not in (0, "", [], None)], indent=" ") + ui.kv(_policy_rows(policy), indent=" ") else: ui.detail("none armed") @@ -1313,6 +1312,25 @@ def _stamp(ts) -> str: return ts.isoformat(sep=" ", timespec="seconds") if ts else "-" +def _policy_rows(policy: dict) -> list[tuple[str, object]]: + """A runtime policy as rows, with `custom` opened up. + + `custom` is Charter's half: BoundFlow carries it and reads none of it, so it + arrives as one opaque dict and printed as one it says nothing. These are + limits an operator is looking for by name. + """ + rows, custom = [], {} + for key, value in sorted(policy.items()): + if key == "custom" and isinstance(value, dict): + custom = value + continue + if value not in (0, "", [], None): + rows.append((_snake(key), _fmt(value))) + rows += [(_snake(k), _fmt(v)) for k, v in sorted(custom.items()) + if v not in (0, "", [], None)] + return rows + + def _snake(key: str) -> str: return "".join(f"_{c.lower()}" if c.isupper() else c for c in key) @@ -1326,9 +1344,14 @@ def _fmt(value): def _one_limit(d: dict) -> str: + """One entry of a limit list. Capability limits name a capability, not a tool, + and a tool limit may carry a proposal ceiling as well as a call one.""" + what = d.get("tool") or d.get("capability") or "?" n = next((d[k] for k in ("maxCalls", "max_calls", "maxFailures", "max_failures") if d.get(k) is not None), "?") - return f"{d.get('tool')}={n}" + proposals = next((d[k] for k in ("maxProposals", "max_proposals") + if d.get(k) is not None), None) + return f"{what}={n}" + (f" ({proposals} proposals)" if proposals else "") def _took(started, finished) -> str: @@ -1450,8 +1473,7 @@ async def go(): for agent_name, policy in sorted(dict(policies).items()): if len(policies) > 1: ui.detail(agent_name) - ui.kv([(_snake(k), _fmt(v)) for k, v in sorted(dict(policy).items())], - indent=" ") + ui.kv(_policy_rows(dict(policy)), indent=" ") if info.invoke_context: given = {k: v for k, v in info.invoke_context.items() if not k.startswith("_")} From 0ba12293c795666a8970b366692e1246e660a3b7 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Wed, 9 Sep 2026 01:43:29 -0400 Subject: [PATCH 43/49] Ship the proposal ceiling in policy rather than the local file BoundFlow's ToolCallLimit caps calls and has no field for asks, so max_proposals was dropped at compile and only ever read from the worker's own runtime.yaml. It travels in `custom` now, beside the other limits Charter enforces itself, and the loop reads it from the governor's policy like it already reads max_wait. Two things this fixes. Lowering the ceiling took a worker restart instead of a `charter apply`. And a worker serving a pulled artifact has no runtime.yaml at all, so its gates ran with no proposal ceiling. --- charter/cli.py | 9 ++++----- charter/policy.py | 15 +++++++++++++++ charter/workflows/loop.py | 20 ++++++++++++++++---- tests/test_compile.py | 14 ++++++++++++++ 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/charter/cli.py b/charter/cli.py index 4f654f7..5f04668 100644 --- a/charter/cli.py +++ b/charter/cli.py @@ -1345,13 +1345,12 @@ def _fmt(value): def _one_limit(d: dict) -> str: """One entry of a limit list. Capability limits name a capability, not a tool, - and a tool limit may carry a proposal ceiling as well as a call one.""" + and proposal limits carry their count under their own key.""" what = d.get("tool") or d.get("capability") or "?" - n = next((d[k] for k in ("maxCalls", "max_calls", "maxFailures", "max_failures") + n = next((d[k] for k in ("maxCalls", "max_calls", "maxFailures", "max_failures", + "maxProposals", "max_proposals") if d.get(k) is not None), "?") - proposals = next((d[k] for k in ("maxProposals", "max_proposals") - if d.get(k) is not None), None) - return f"{what}={n}" + (f" ({proposals} proposals)" if proposals else "") + return f"{what}={n}" def _took(started, finished) -> str: diff --git a/charter/policy.py b/charter/policy.py index 3e351ac..f4a1544 100644 --- a/charter/policy.py +++ b/charter/policy.py @@ -17,6 +17,7 @@ # Keys under `RuntimePolicy.custom`. Named once. CAPABILITY_CALL_LIMITS = "capability_call_limits" +TOOL_PROPOSAL_LIMITS = "tool_proposal_limits" # Charter and the harness enforce these; BoundFlow has no field for them because # they are this harness's vocabulary, not a control plane's. They travel so a # worker holding only an artifact still has them — behaviour comes from the @@ -63,6 +64,14 @@ def build(cfg, per_run, limits, authority, operation_timeout: int) -> dict[str, {"capability": l.capability, "max_calls": l.max_calls} for l in per_run.capability_call_limits] + # BoundFlow's ToolCallLimit has max_calls and no ceiling on *asking*, so the + # proposal cap travels here alongside the call cap it sits next to in + # runtime.yaml, and moves with `charter apply` like every other limit. + proposals = [{"tool": l.tool, "max_proposals": l.max_proposals} + for l in per_run.tool_call_limits if l.max_proposals] + if proposals: + custom[TOOL_PROPOSAL_LIMITS] = proposals + if authority.allowed_spawns: custom[ALLOWED_SPAWNS] = list(authority.allowed_spawns) custom[APPROVAL_TIMEOUT_SECONDS] = authority.approval_timeout_seconds @@ -135,6 +144,12 @@ def allowed_tools(policy) -> set[str]: return set(_of(policy).get(ALLOWED_TOOLS) or []) +def proposal_caps(policy) -> dict[str, int]: + """Tool -> how many times one task may propose it at a gate.""" + return {l["tool"]: int(l["max_proposals"]) + for l in (_of(policy).get(TOOL_PROPOSAL_LIMITS) or [])} + + def capability_call_caps(policy) -> dict[str, int]: """Capability -> cap, for the middleware that enforces it. diff --git a/charter/workflows/loop.py b/charter/workflows/loop.py index ad49b0a..265dc88 100644 --- a/charter/workflows/loop.py +++ b/charter/workflows/loop.py @@ -427,7 +427,7 @@ async def entry(self, ctx): # ours, and failing a task because we declined to ask would punish the # agent for a limit the operator set. tool = ctx.context.pop(K_SPENT_ASKS, "that tool") - cap = self._proposal_cap(tool) + cap = self._proposal_cap(ctx, tool) decision = reject( f"Proposal limit reached for {tool!r} (max {cap}). Nobody was asked " f"this time. Do not propose it again; finish with what you have.") @@ -673,7 +673,7 @@ def resume(verdict: str): return self._sleep(ctx, action, c) asked = dict(c.get(K_ASKS) or {}) - cap = self._proposal_cap(tool) + cap = self._proposal_cap(ctx, tool) if cap and asked.get(tool, 0) >= cap: # Spent, so the agent is told rather than a person being asked again. # The call does not run: it was gated, and nobody approved it. @@ -772,8 +772,20 @@ def _gate_timeout(self, tool: str) -> int: return spec.approval_timeout_seconds return self.runtime.authority.approval_timeout_seconds - def _proposal_cap(self, tool: str) -> int | None: - """How many times this tool may be proposed in one task, if capped.""" + def _proposal_cap(self, ctx, tool: str) -> int | None: + """How many times this tool may be proposed in one task, if capped. + + From the governor's policy like `_max_wait`, so lowering the ceiling takes + a `charter apply` and not a worker restart. Falls back to the copy loaded + at boot when the governor can't be reached. + """ + from .. import policy as charter_policy + try: + live = charter_policy.proposal_caps(ctx.agent_governor(self.cfg.name).policy) + except Exception: # noqa: BLE001 + live = {} + if tool in live: + return live[tool] for limit in self.runtime.per_run.tool_call_limits: if limit.tool == tool: return limit.max_proposals diff --git a/tests/test_compile.py b/tests/test_compile.py index d08be73..3b34ec5 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -189,6 +189,20 @@ def test_charters_own_limits_ride_in_custom(): assert isinstance(custom["allowed_capabilities"], list) +def test_the_proposal_ceiling_travels_in_policy(): + """BoundFlow's ToolCallLimit caps calls, not asks, so a worker reading only the + typed field would gate without a ceiling. Left out of `custom`, the cap lived + in the worker's local runtime.yaml: unreachable by `charter apply` and absent + entirely from a worker serving a pulled artifact. + """ + from charter import policy + + compiled = refund().runtime_policy + + assert all(l.max_calls for l in compiled.tool_call_limits) + assert policy.proposal_caps(compiled) == {"support__create_refund": 3} + + def test_writing_and_reading_custom_cannot_drift(): """Both ends live in charter/policy.py for this reason: a key spelled one way when written and another when read is a policy that silently stops applying, From 995660adc1a3758f95b7b417fefedbeb095caa5e Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Wed, 9 Sep 2026 01:51:33 -0400 Subject: [PATCH 44/49] Read runtime policy from the control plane on every worker A worker serving a checkout read its caps from the runtime.yaml on its disk; only a worker serving a pulled artifact read them back from the control plane. So `charter apply` could not lower a cap without a restart, and two workers on different checkouts of the same agent enforced different numbers. Both paths now read the applied policy, and the local file stands in only until the first apply. Fixes a crash this exposed: tool_failure_limits is emitted per declared tool, so an agent that calls none applies a policy carrying an empty list, and rebuilding runtime.yaml from it fed 0 to a field requiring a positive number. That is the agent `charter init` writes. --- charter/policy.py | 5 +++-- charter/worker.py | 48 +++++++++++++++++++++++++++---------------- tests/test_compile.py | 15 ++++++++++++++ tests/test_worker.py | 36 ++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 20 deletions(-) diff --git a/charter/policy.py b/charter/policy.py index f4a1544..566ce6f 100644 --- a/charter/policy.py +++ b/charter/policy.py @@ -194,8 +194,9 @@ def get(name, default=0): max_parallel_subagents=mine["max_parallel_subagents"], # One number covers every tool, because that is how Charter declares it # — the policy carries it per tool only because BoundFlow's field is - # shaped that way. - max_tool_failures=max(per_tool) if per_tool else 0, + # shaped that way. An agent with no tools has no limits to carry, and + # 0 is not a value the field takes, so the default stands. + **({"max_tool_failures": max(per_tool)} if per_tool else {}), ), limits=Limits( max_tokens_per_call=get("max_tokens_per_call", 1024), diff --git a/charter/worker.py b/charter/worker.py index d0da5d5..83e7ed1 100644 --- a/charter/worker.py +++ b/charter/worker.py @@ -211,16 +211,38 @@ async def _bundle_for(self, cp: ControlPlaneClient, spec): is the whole point of the artifact being a plain tarball of the layout it already expects. - An artifact carries no runtime.yaml, because that is policy and policy is - applied rather than shipped. Its numbers come back from the control plane - instead, so a worker serving an artifact enforces exactly what a worker - serving a checkout does. + Either way the runtime policy comes from the control plane, not from the + directory. `charter apply` is what puts it there, and a worker that read + the local runtime.yaml instead would enforce whatever was on its disk at + boot — so lowering a cap would take a restart, and two workers serving the + same agent from different checkouts would enforce different numbers. """ - if not spec.from_registry: - bundle = self.project.agents[spec.agent] - return bundle, spec.versions + from . import policy as charter_policy - from . import artifact, policy as charter_policy + if spec.from_registry: + bundle, versions = await self._pull(spec) + else: + bundle, versions = self.project.agents[spec.agent], spec.versions + + wf = await self._workflow_for(cp, bundle.name) + if wf is not None: + live = await cp.get_agent_runtime_policy(wf.id, bundle.name) + bundle.runtime = charter_policy.runtime_file(bundle.name, live) + else: + # Nothing applied yet, so there is no policy to read. The local file + # stands in until `charter apply` runs, which is the next boot. + log.warning("%s: no instance on the control plane yet — running on " + "the limits in its directory until one is applied", + bundle.name) + return bundle, versions + + async def _pull(self, spec): + """Fetch a registry ref and read it as a directory. + + An artifact holds `v.yaml` and `v/skills/` and nothing else, so the + unpacked tree is the layout `load_agent` already reads. + """ + from . import artifact from .config.loader import load_agent if spec.repository: @@ -246,16 +268,6 @@ async def _bundle_for(self, cp: ControlPlaneClient, spec): versions = [max(bundle.versions)] log.info("pulled %s v%d from %s", bundle.name, versions[0], spec.ref) - wf = await self._workflow_for(cp, bundle.name) - if wf is not None: - live = await cp.get_agent_runtime_policy(wf.id, bundle.name) - bundle.runtime = charter_policy.runtime_file(bundle.name, live) - else: - # Nothing applied yet, so there is no policy to read. The defaults are - # the ones runtime.yaml would have given, and `charter apply` replaces - # them on the next boot. - log.warning("%s: no instance on the control plane yet — running on " - "default limits until one is applied", bundle.name) return bundle, versions async def _workflow_for(self, cp: ControlPlaneClient, agent: str): diff --git a/tests/test_compile.py b/tests/test_compile.py index 3b34ec5..3317b82 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -247,3 +247,18 @@ def test_a_dying_worker_hands_the_operation_over_rather_than_stopping_the_agent( needs an operator because a process died is not a durable agent. """ assert compile_agent(load_agent(EXAMPLES / "refund-triage")).workflow_config.resumable is True + + +def test_an_agent_with_no_tools_can_be_rebuilt_from_its_policy(): + """`tool_failure_limits` is emitted per declared tool, so an agent that calls + none applies a policy carrying an empty list. Rebuilding a runtime.yaml from + that fed 0 to a field that requires a positive number, and the worker died at + boot — which is every worker serving the agent `charter init` writes. + """ + from charter import policy + + applied = type("P", (), {"custom": {}, "max_cost_usd": 0.5, + "tool_failure_limits": []})() + rebuilt = policy.runtime_file("triage", applied) + + assert rebuilt.per_run.max_tool_failures > 0 diff --git a/tests/test_worker.py b/tests/test_worker.py index 08f353d..b701700 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -74,3 +74,39 @@ def built(*a, **k): with pytest.raises(RuntimeError) as e: asyncio.run(run_worker(load_project(tmp_path / "worker.yaml"))) assert "ANTHROPIC_API_KEY" in str(e.value) + + +def test_a_checkout_worker_takes_its_limits_from_the_control_plane(monkeypatch): + """runtime.yaml is policy, so `charter apply` owns it and the worker reads it + back. Read off local disk instead, a lowered cap needs a worker restart, and + two workers serving the same agent from different checkouts enforce different + numbers. The registry path always did this; the checkout path did not. + """ + from types import SimpleNamespace + + from charter.config.loader import load_agent + from charter.config.worker import Served as ServedSpec + from charter.worker import CharterWorker + + bundle = load_agent(Path(__file__).parent.parent / "examples" / "refund-triage") + assert bundle.runtime.per_run.max_cost_usd == 0.30, "the file on disk" + + applied = SimpleNamespace(custom={}, max_cost_usd=0.05, max_llm_calls=7) + cp = SimpleNamespace( + get_agent_runtime_policy=lambda *_: _async(applied), + list_workflows=lambda: _async([])) + + stub = SimpleNamespace( + project=SimpleNamespace(agents={"refund-triage": bundle}), + _tenant_id="t", + _workflow_for=lambda *_: _async(SimpleNamespace(id="w"))) + + spec = ServedSpec(agent="refund-triage", versions=[1]) + got, _ = asyncio.run(CharterWorker._bundle_for(stub, cp, spec)) + + assert got.runtime.per_run.max_cost_usd == 0.05, "applied policy, not the file" + assert got.runtime.per_run.max_llm_calls == 7 + + +async def _async(value): + return value From bed0a94377b985ed2edcf98e869de85840aad43f Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Wed, 9 Sep 2026 01:57:27 -0400 Subject: [PATCH 45/49] Stop the worker reading policy files at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `load_agent(policy=False)` leaves runtime.yaml and lifecycle.yaml unopened, and `charter worker` uses it. The worker never acted on lifecycle.yaml, and no longer needs runtime.yaml now that its caps come from the control plane — so a malformed policy file in the directory stopped a worker booting over something it would never read. Every other command reads them as before. The proposal cap follows: one read, from applied policy, with no fall back to a file the worker no longer opens. --- DESIGN.md | 5 ++++- charter/cli.py | 17 +++++++++++------ charter/config/loader.py | 24 +++++++++++++++++------- charter/worker.py | 12 ++++++------ charter/workflows/loop.py | 17 ++++++----------- tests/test_loader.py | 26 ++++++++++++++++++++++++++ tests/test_loop.py | 24 ++++++++++++------------ 7 files changed, 82 insertions(+), 43 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 5b21cff..b89eea7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -218,7 +218,10 @@ plane — which is what makes them different from `store.url`, holding the check and files a parked task resumes from. At boot the worker loads each listed config version and calls -`worker.workflow(agent, version=N)` once per version. There is one operation — +`worker.workflow(agent, version=N)` once per version. It reads only what the agent +is — `v.yaml` and its skills. `runtime.yaml` and `lifecycle.yaml` are policy, +applied rather than served, and the caps come back from the control plane, so the +same numbers hold whether the worker was given a checkout or a pulled artifact. There is one operation — the entry handler — re-entered after every park. `serves` is what makes a worker fleet-manageable: which process can run which agent is declarative, so you can shard agents across workers, or run a canary worker holding only `v2` while the fleet stays on `v1`. diff --git a/charter/cli.py b/charter/cli.py index 5f04668..8ac33fb 100644 --- a/charter/cli.py +++ b/charter/cli.py @@ -126,16 +126,20 @@ def sub(m: re.Match) -> str: return out -def _load(path: Path): +def _load(path: Path, *, policy: bool = True): """Load whatever `path` points at — a worker manifest, a project directory, or a - single agent directory.""" + single agent directory. + + `policy=False` leaves runtime.yaml and lifecycle.yaml unread, which is what + `charter worker` wants and what every other command must not have. + """ path = Path(path) if path.is_file(): - return load_project(path) + return load_project(path, policy=policy) if (path / "worker.yaml").exists(): - return load_project(path / "worker.yaml") + return load_project(path / "worker.yaml", policy=policy) try: - return load_agent(path) + return load_agent(path, policy=policy) except ConfigError: raise except Exception as e: # noqa: BLE001 — a bug here must still read as a message @@ -2067,7 +2071,8 @@ def worker( ) -> None: """Run the generic worker process.""" try: - project = _load(path) + # Policy is applied, not served: the caps come back from the control plane. + project = _load(path, policy=False) except ConfigError as e: _fail(e) diff --git a/charter/config/loader.py b/charter/config/loader.py index bab6657..9bd795b 100644 --- a/charter/config/loader.py +++ b/charter/config/loader.py @@ -92,8 +92,14 @@ def declared_tools(self) -> set[str]: return {t for cfg in self.versions.values() for t in cfg.all_tools} -def load_agent(path: Path) -> AgentBundle: - """Load one agent directory. Raises ConfigError listing every problem found.""" +def load_agent(path: Path, *, policy: bool = True) -> AgentBundle: + """Load one agent directory. Raises ConfigError listing every problem found. + + `policy=False` reads only what the agent *is* — its versions and their skills — + and leaves runtime.yaml and lifecycle.yaml on disk unopened. That is what a + worker wants: policy is applied, so a worker reads it back from the control + plane, and a file it will never act on has no business failing its boot. + """ problems: list[str] = [] # Resolved so `.name` is a real directory name: Path(".").name is "", which # would otherwise reach pydantic as an agent named "". @@ -133,10 +139,11 @@ def load_agent(path: Path) -> AgentBundle: # management you add once you have a fleet. runtime_path = path / RUNTIME_FILE runtime = (_parse(RuntimePolicyFile, runtime_path, problems) - if runtime_path.exists() else default_runtime(path.name)) + if policy and runtime_path.exists() else default_runtime(path.name)) lifecycle_path = path / LIFECYCLE_FILE - lifecycle = _parse(LifecyclePolicyFile, lifecycle_path, problems) if lifecycle_path.exists() else None + lifecycle = (_parse(LifecyclePolicyFile, lifecycle_path, problems) + if policy and lifecycle_path.exists() else None) if problems: raise ConfigError(problems) @@ -239,9 +246,12 @@ def load_worker(worker_yaml: Path) -> WorkerManifest: return manifest -def load_project(worker_yaml: Path) -> Project: +def load_project(worker_yaml: Path, *, policy: bool = True) -> Project: """Load a worker manifest and the agents it serves. Raises ConfigError listing - every problem across every file.""" + every problem across every file. + + `policy=False` is passed straight to `load_agent`. + """ problems: list[str] = [] worker_yaml = Path(worker_yaml) @@ -264,7 +274,7 @@ def load_project(worker_yaml: Path) -> Project: f"{worker_yaml.name}: serves {served.agent!r}, but {agent_dir} does not exist") continue try: - agents[served.agent] = load_agent(agent_dir) + agents[served.agent] = load_agent(agent_dir, policy=policy) except ConfigError as e: problems.extend(f"{served.agent}/{p}" for p in e.problems) diff --git a/charter/worker.py b/charter/worker.py index 83e7ed1..fffe6bd 100644 --- a/charter/worker.py +++ b/charter/worker.py @@ -229,11 +229,11 @@ async def _bundle_for(self, cp: ControlPlaneClient, spec): live = await cp.get_agent_runtime_policy(wf.id, bundle.name) bundle.runtime = charter_policy.runtime_file(bundle.name, live) else: - # Nothing applied yet, so there is no policy to read. The local file - # stands in until `charter apply` runs, which is the next boot. + # Nothing applied yet, so there is no policy to read and the worker + # never read the directory's. Conservative defaults hold until + # `charter apply` runs, which is the next boot. log.warning("%s: no instance on the control plane yet — running on " - "the limits in its directory until one is applied", - bundle.name) + "default limits until one is applied", bundle.name) return bundle, versions async def _pull(self, spec): @@ -252,7 +252,7 @@ async def _pull(self, spec): ref = artifact.ref_for(spec.repository, spec.agent, version) directory = artifact.pull(ref, self._pulled, insecure=spec.insecure) log.info("pulled %s v%d from %s", spec.agent, version, ref) - bundle = load_agent(directory) + bundle = load_agent(directory, policy=False) versions = sorted(spec.versions) absent = [v for v in versions if v not in bundle.versions] if absent: @@ -264,7 +264,7 @@ async def _pull(self, spec): f"{', '.join(f'v{v}' for v in sorted(bundle.versions))}") else: directory = artifact.pull(spec.ref, self._pulled, insecure=spec.insecure) - bundle = load_agent(directory) + bundle = load_agent(directory, policy=False) versions = [max(bundle.versions)] log.info("pulled %s v%d from %s", bundle.name, versions[0], spec.ref) diff --git a/charter/workflows/loop.py b/charter/workflows/loop.py index 265dc88..81e1a87 100644 --- a/charter/workflows/loop.py +++ b/charter/workflows/loop.py @@ -775,21 +775,16 @@ def _gate_timeout(self, tool: str) -> int: def _proposal_cap(self, ctx, tool: str) -> int | None: """How many times this tool may be proposed in one task, if capped. - From the governor's policy like `_max_wait`, so lowering the ceiling takes - a `charter apply` and not a worker restart. Falls back to the copy loaded - at boot when the governor can't be reached. + From the governor's policy, which is where the worker's caps come from — + it never reads the directory's runtime.yaml. Unreachable governor means no + ceiling, which is the same answer as a tool that declares none. """ from .. import policy as charter_policy try: - live = charter_policy.proposal_caps(ctx.agent_governor(self.cfg.name).policy) + caps = charter_policy.proposal_caps(ctx.agent_governor(self.cfg.name).policy) except Exception: # noqa: BLE001 - live = {} - if tool in live: - return live[tool] - for limit in self.runtime.per_run.tool_call_limits: - if limit.tool == tool: - return limit.max_proposals - return None + return None + return caps.get(tool) def _on_reject(self, tool: str) -> str: """What a refusal of this tool means, per tool where it says. diff --git a/tests/test_loader.py b/tests/test_loader.py index ebdc655..7a4fb64 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -360,3 +360,29 @@ def test_derived_ref_matches_what_push_writes(self): def test_a_trailing_slash_does_not_double(self): assert (artifact.ref_for("ghcr.io/acme/agents/", "leads-finder", 1) == "ghcr.io/acme/agents/leads-finder:v1") + + +class TestTheWorkerDoesNotReadPolicy: + """runtime.yaml and lifecycle.yaml are applied, not served. The worker reads its + caps back from the control plane, so opening those files could only make it + refuse to boot over something it will never act on. + """ + + def test_a_broken_policy_file_does_not_stop_the_worker(self, project): + (project / "refund-triage" / "lifecycle.yaml").write_text("rules: nonsense\n") + (project / "refund-triage" / "runtime.yaml").write_text("per_run: 4\n") + + bundle = load_agent(project / "refund-triage", policy=False) + + assert bundle.latest.name == "refund-triage", "the agent still loads" + # The same directory still fails for every command that does read policy. + with pytest.raises(ConfigError): + load_agent(project / "refund-triage") + + def test_the_files_are_left_unread(self, project): + """Not just tolerated — never opened. A cap read here would be the one on + disk at boot, and lowering it would take a restart rather than an apply.""" + bundle = load_agent(project / "refund-triage", policy=False) + + assert bundle.lifecycle is None + assert bundle.runtime.per_run.max_cost_usd == 1.00, "the default, not 0.30" diff --git a/tests/test_loop.py b/tests/test_loop.py index 5f5a374..3569f41 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -930,30 +930,30 @@ class TestProposalCap: an agent can put the same decision in front of someone all afternoon. """ - def _loop(self, cap): - cfg, loop = loop_for() - loop.runtime.per_run.tool_call_limits = [ - type(loop.runtime.per_run.tool_call_limits[0])( - tool="support__create_refund", max_calls=1, max_proposals=cap)] - return loop + def _ctx(self, cap, **kw): + """The cap comes from applied policy. A worker never reads the directory's + runtime.yaml, so setting it on the loaded config would prove nothing.""" + custom = {"tool_proposal_limits": [ + {"tool": "support__create_refund", "max_proposals": cap}]} if cap else {} + return FakeCtx(policy_custom=custom, **kw) def test_it_gates_until_the_cap(self): - loop = self._loop(2) - ctx = FakeCtx(context={}) + _, loop = loop_for() + ctx = self._ctx(2, context={}) out = loop._gate(ctx, {"name": "support__create_refund", "args": {}}) assert isinstance(out, AwaitApproval) assert ctx.context["_asks"]["support__create_refund"] == 1 def test_past_the_cap_nobody_is_asked(self): - loop = self._loop(1) - ctx = FakeCtx(context={"_asks": {"support__create_refund": 1}}) + _, loop = loop_for() + ctx = self._ctx(1, context={"_asks": {"support__create_refund": 1}}) out = loop._gate(ctx, {"name": "support__create_refund", "args": {}}) assert not isinstance(out, AwaitApproval), "a person was asked past the cap" assert out.context["_decision"] == "asked_enough" def test_no_cap_means_no_ceiling(self): - loop = self._loop(None) - ctx = FakeCtx(context={"_asks": {"support__create_refund": 99}}) + _, loop = loop_for() + ctx = self._ctx(None, context={"_asks": {"support__create_refund": 99}}) out = loop._gate(ctx, {"name": "support__create_refund", "args": {}}) assert isinstance(out, AwaitApproval) From 7a76fb6484cc37fe240dc660ef07ef12512940b2 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Wed, 9 Sep 2026 02:43:56 -0400 Subject: [PATCH 46/49] Read `custom` policy off the wire as well as off the compiler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK returns a typed object when a policy is written and protobuf JSON — a plain dict — when one is read back. `_of` only ever looked at the attribute, so every reader of `custom` saw an empty dict on the read path: an allowlist read as no allowlist, a cap as no cap, and `allowed_spawns` as nothing at all. Already broken for a worker serving a pulled artifact, which has always taken its policy from the wire. Reading applied policy on the checkout path too is what made it visible. Also prints counts as integers. Protobuf JSON has one number type, so every ceiling came back as 40.0. --- charter/cli.py | 10 +++++++++- charter/policy.py | 9 +++++++++ charter/worker.py | 5 +++-- tests/test_compile.py | 19 +++++++++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/charter/cli.py b/charter/cli.py index 8ac33fb..c21771f 100644 --- a/charter/cli.py +++ b/charter/cli.py @@ -1344,6 +1344,14 @@ def _fmt(value): read path, snake_case from a pydantic dump on the compile path.""" if isinstance(value, list): return ", ".join(_one_limit(d) if isinstance(d, dict) else str(d) for d in value) + return _num(value) + + +def _num(value): + """Counts read back as floats, because protobuf JSON has one number type. A + ceiling of 40 calls printed as 40.0 reads like a number someone computed.""" + if isinstance(value, float) and value.is_integer(): + return int(value) return value @@ -1354,7 +1362,7 @@ def _one_limit(d: dict) -> str: n = next((d[k] for k in ("maxCalls", "max_calls", "maxFailures", "max_failures", "maxProposals", "max_proposals") if d.get(k) is not None), "?") - return f"{what}={n}" + return f"{what}={_num(n)}" def _took(started, finished) -> str: diff --git a/charter/policy.py b/charter/policy.py index 566ce6f..7ec9ccf 100644 --- a/charter/policy.py +++ b/charter/policy.py @@ -129,6 +129,15 @@ def timeouts(policy) -> dict[str, int]: def _of(policy) -> dict[str, Any]: + """`custom`, from either shape a policy arrives in. + + The SDK hands back a typed object on the write path and protobuf JSON — a + plain dict — on the read path. Reading only the attribute meant every caller + saw an empty policy whenever it came off the wire, which is silent: an + allowlist reads as "no allowlist" and a cap as "no cap". + """ + if isinstance(policy, dict): + return policy.get("custom") or {} return getattr(policy, "custom", None) or {} diff --git a/charter/worker.py b/charter/worker.py index fffe6bd..62988c9 100644 --- a/charter/worker.py +++ b/charter/worker.py @@ -232,8 +232,9 @@ async def _bundle_for(self, cp: ControlPlaneClient, spec): # Nothing applied yet, so there is no policy to read and the worker # never read the directory's. Conservative defaults hold until # `charter apply` runs, which is the next boot. - log.warning("%s: no instance on the control plane yet — running on " - "default limits until one is applied", bundle.name) + log.warning("%s: no instance on the control plane holds policy for " + "this agent — running on default limits until " + "`charter apply` sets some", bundle.name) return bundle, versions async def _pull(self, spec): diff --git a/tests/test_compile.py b/tests/test_compile.py index 3317b82..f58eba3 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -262,3 +262,22 @@ def test_an_agent_with_no_tools_can_be_rebuilt_from_its_policy(): rebuilt = policy.runtime_file("triage", applied) assert rebuilt.per_run.max_tool_failures > 0 + + +def test_custom_reads_the_same_off_the_wire_as_off_the_compiler(): + """The SDK returns a typed object when you write a policy and protobuf JSON — + a plain dict — when you read one back. Reading only the attribute made every + cap and allowlist vanish on the read path, silently: a worker booting from + applied policy enforced nothing it declared. + """ + from charter import policy + + compiled = refund().runtime_policy + off_the_wire = {"maxCostUsd": 0.30, "custom": dict(compiled.custom)} + + assert policy.allowed_capabilities(off_the_wire) == \ + policy.allowed_capabilities(compiled) + assert policy.capability_call_caps(off_the_wire) == \ + policy.capability_call_caps(compiled) + assert policy.proposal_caps(off_the_wire) == {"support__create_refund": 3} + assert policy.timeouts(off_the_wire) == policy.timeouts(compiled) From 51a46dc454e4af41c0105b178b8e51d559fe0c36 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Thu, 10 Sep 2026 02:23:27 -0400 Subject: [PATCH 47/49] Cover a lifecycle rule firing, and stop pointing at the deleted demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in tests/e2e asserted that a metric crossing a threshold does anything. `test_lifecycle.py` covered gates only, so "policy that acts" — the first claim in the README — was verified by hand or not at all. The rule is armed here rather than as a playground lifecycle.yaml, which would arm it for every other test serving that agent. approval_rejections is the metric worth covering first: it is counted when a gate is answered, so a worker lost mid-operation dropped it and a pause rule reading it under-counted in silence. The README still offered the demo this branch deletes. --- README.md | 2 +- tests/e2e/test_lifecycle.py | 63 +++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1266432..7027b9d 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ export ANTHROPIC_API_KEY= # or another provider's, see worker. ``` Remove it with `docker compose -f local.compose.yml down -v`. Cloning the repo -works too, and gets you the examples and the demo alongside it. +works too, and gets you the examples alongside it. For production you have two options. Run the BoundFlow backend yourself, following its [deployment docs](https://github.com/boundflow/boundflow/blob/main/docs/deployment.md). diff --git a/tests/e2e/test_lifecycle.py b/tests/e2e/test_lifecycle.py index 2b41431..3253193 100644 --- a/tests/e2e/test_lifecycle.py +++ b/tests/e2e/test_lifecycle.py @@ -144,3 +144,66 @@ async def test_a_rejection_reaches_the_model(cp, project, tenant): assert info.status.value == "completed", info.failure_reason assert info.result["refunded_usd"] == 0 + + +async def arm(cp, wf, *rules): + """Arm workflow lifecycle rules, built the way `charter apply` builds them. + + Written here rather than added to `playground/` as a lifecycle.yaml: these + rules act on the agent across runs, so a file would arm them for every other + test that serves the same agent. + """ + from charter.compile import compile_workflow_rules + from charter.config.lifecycle import LifecyclePolicyFile + + compiled = compile_workflow_rules(LifecyclePolicyFile.model_validate({ + "apiVersion": "charter/v1", "kind": "LifecyclePolicy", + "agent": wf.workflow_type, "rules": list(rules)})) + await cp.set_workflow_lifecycle_policy(wf.id, compiled) + + +async def state_becomes(cp, wf_id, wanted, timeout=90): + """Poll for a workflow state. Rules are evaluated between runs, so the change + lands after the run that crossed the threshold has finished, not during it.""" + import asyncio + + deadline = asyncio.get_event_loop().time() + timeout + seen = None + while asyncio.get_event_loop().time() < deadline: + wf = next(w for w in await cp.list_workflows() if w.id == wf_id) + seen = wf.workflow_state.value + if seen == wanted: + return wf + await asyncio.sleep(2) + raise AssertionError(f"workflow_state stayed {seen!r}, wanted {wanted!r}") + + +async def test_a_rejection_threshold_pauses_the_agent(cp, project, tenant): + """The README's claim, and the one thing no test covered: a metric crossing a + threshold stops the agent on its own. + + `approval_rejections` is the metric that goes wrong quietly — it is counted + when a gate is answered, so a worker that dies mid-operation used to drop it, + and a pause rule reading it silently under-counted. + """ + wf = await one_instance(cp, project, "refund-demo", tenant) + await arm(cp, wf, {"when": {"metric": "approval_rejections", "threshold": 1}, + "then": {"pause": {"window": 1}}}) + + model = scripted( + calls("desk__create_refund", charge_id="ch_7700", amount_usd=89, + reason="changed their mind"), + submits(resolution="no refund", refunded_usd=0), + ) + + worker = CharterWorker(project, chat_model=factory(model)) + async with running(worker): + request_id = await cp.invoke_workflow(wf.id, context={"ticket_id": "5150"}) + gate = await wait_for_gate(cp, wf.id, timeout=90) + await cp.reject_workflow(wf.id, gate.approval_id, "e2e", "outside the window") + info = await wait_for_run(cp, request_id, timeout=90) + assert info.status.value == "completed", info.failure_reason + + paused = await state_becomes(cp, wf.id, "paused") + + assert paused.workflow_state.value == "paused" From f124a4b54cd437c5b8b07e294355f999ada9da21 Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Thu, 10 Sep 2026 03:15:48 -0400 Subject: [PATCH 48/49] Make the refund example's pause rule reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule summed four rejections over the last three runs. A rejection that says no, which is what the README tells you to give, ends the run after one proposal, so three runs held at most three and the agent never paused — seventeen rejections on a live control plane and it stayed active. Two proposals per run and three rejections over three runs fires on the third run whichever way you reject: once per run from a flat no, or twice from a reason the agent revises against, held back until the window has three runs. Also adds `charter tenant create default` to the examples README, whose first command failed without it, and reads the expected proposal cap from the example in two compiler tests rather than restating the number. --- examples/README.md | 9 +++++---- examples/refund-triage/lifecycle.yaml | 4 ++-- examples/refund-triage/runtime.yaml | 4 ++-- tests/e2e/test_lifecycle.py | 7 ++++--- tests/test_compile.py | 12 ++++++++++-- 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/examples/README.md b/examples/README.md index 450aab2..74a4576 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,6 +23,7 @@ reach it by URL: In a second terminal, from this directory, bring up an agent and a worker: + charter tenant create default # once per control plane charter agent create refund-triage charter apply . charter worker . @@ -73,10 +74,10 @@ Approve that one and the task finishes: other $48.00 charge was authorized by the customer and remains. The `runtime.yaml` we applied lets one refund go through per task, and lets the -agent propose three. A rejected proposal never runs, so it costs nothing against -the first number. The second is what stops it asking you a fourth time. +agent propose twice. A rejected proposal never runs, so it costs nothing against +the first number. The second is what stops it asking you a third time. -The `lifecycle.yaml` we applied pauses the agent after four rejections across the +The `lifecycle.yaml` we applied pauses the agent after three rejections across the last three runs. Keep rejecting, on this ticket or another, until it stops: charter describe refund-triage --instance @@ -86,7 +87,7 @@ last three runs. Keep rejecting, on this ticket or another, until it stops: status paused activity active -If the fourth rejection lands mid-run, the agent stays active until that run +If the third rejection lands mid-run, the agent stays active until that run finishes. Lifecycle rules are evaluated between runs, not during one, so they decide whether the next run starts rather than stopping the one in flight. diff --git a/examples/refund-triage/lifecycle.yaml b/examples/refund-triage/lifecycle.yaml index a95bb6f..c0732e5 100644 --- a/examples/refund-triage/lifecycle.yaml +++ b/examples/refund-triage/lifecycle.yaml @@ -4,7 +4,7 @@ kind: LifecyclePolicy agent: refund-triage rules: - # If 4 refund attempts are rejected in the last 3 runs, stay paused until someone + # If 3 refund attempts are rejected in the last 3 runs, stay paused until someone # runs `charter resume`. - - when: { metric: approval_rejections, threshold: 4 } + - when: { metric: approval_rejections, threshold: 3 } then: { pause: { window: 3 } } diff --git a/examples/refund-triage/runtime.yaml b/examples/refund-triage/runtime.yaml index d40d695..f6893ba 100644 --- a/examples/refund-triage/runtime.yaml +++ b/examples/refund-triage/runtime.yaml @@ -8,10 +8,10 @@ per_run: max_cost_usd: 0.30 max_llm_calls: 40 tool_call_limits: - # This tool may be proposed by the agent 3 times per run but can only be run once + # This tool may be proposed by the agent twice per run but can only be run once - tool: support__create_refund max_calls: 1 - max_proposals: 3 + max_proposals: 2 - tool: support__get_charge max_calls: 5 - tool: support__get_ticket diff --git a/tests/e2e/test_lifecycle.py b/tests/e2e/test_lifecycle.py index 3253193..56120d4 100644 --- a/tests/e2e/test_lifecycle.py +++ b/tests/e2e/test_lifecycle.py @@ -146,7 +146,7 @@ async def test_a_rejection_reaches_the_model(cp, project, tenant): assert info.result["refunded_usd"] == 0 -async def arm(cp, wf, *rules): +async def arm(cp, wf, agent, *rules): """Arm workflow lifecycle rules, built the way `charter apply` builds them. Written here rather than added to `playground/` as a lifecycle.yaml: these @@ -158,7 +158,7 @@ async def arm(cp, wf, *rules): compiled = compile_workflow_rules(LifecyclePolicyFile.model_validate({ "apiVersion": "charter/v1", "kind": "LifecyclePolicy", - "agent": wf.workflow_type, "rules": list(rules)})) + "agent": agent, "rules": list(rules)})) await cp.set_workflow_lifecycle_policy(wf.id, compiled) @@ -187,7 +187,8 @@ async def test_a_rejection_threshold_pauses_the_agent(cp, project, tenant): and a pause rule reading it silently under-counted. """ wf = await one_instance(cp, project, "refund-demo", tenant) - await arm(cp, wf, {"when": {"metric": "approval_rejections", "threshold": 1}, + await arm(cp, wf, "refund-demo", + {"when": {"metric": "approval_rejections", "threshold": 1}, "then": {"pause": {"window": 1}}}) model = scripted( diff --git a/tests/test_compile.py b/tests/test_compile.py index f58eba3..1260cfc 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -25,6 +25,13 @@ def compiled_rules(*rules): "agent": "refund-triage", "rules": list(rules)})) +def declared_proposal_caps(): + """What the example's runtime.yaml declares, read rather than restated, so + tuning the example does not break a test about the compiler.""" + per_run = load_agent(EXAMPLES / "refund-triage").runtime.per_run + return {l.tool: l.max_proposals for l in per_run.tool_call_limits if l.max_proposals} + + def summarizer(version=None): return compile_agent(load_agent(EXAMPLES / "ticket-summarizer"), version) @@ -200,7 +207,8 @@ def test_the_proposal_ceiling_travels_in_policy(): compiled = refund().runtime_policy assert all(l.max_calls for l in compiled.tool_call_limits) - assert policy.proposal_caps(compiled) == {"support__create_refund": 3} + assert declared_proposal_caps(), "the example should declare a proposal cap" + assert policy.proposal_caps(compiled) == declared_proposal_caps() def test_writing_and_reading_custom_cannot_drift(): @@ -279,5 +287,5 @@ def test_custom_reads_the_same_off_the_wire_as_off_the_compiler(): policy.allowed_capabilities(compiled) assert policy.capability_call_caps(off_the_wire) == \ policy.capability_call_caps(compiled) - assert policy.proposal_caps(off_the_wire) == {"support__create_refund": 3} + assert policy.proposal_caps(off_the_wire) == declared_proposal_caps() assert policy.timeouts(off_the_wire) == policy.timeouts(compiled) From 9e097cbaa6191bb84499289bb3dc7c6864a1025e Mon Sep 17 00:00:00 2001 From: Arjun Lama Date: Thu, 10 Sep 2026 03:22:17 -0400 Subject: [PATCH 49/49] Show --actor wherever an approval is answered The audit records who decided, but nothing told anyone to say who they were, so every decision made from the CLI read `approved by (no actor)`. The flag worked; the docs and the commands `charter pending` prints for copying never used it. Both now do. --- DESIGN.md | 4 ++-- README.md | 2 +- charter/cli.py | 8 ++++---- examples/README.md | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index b89eea7..2af5773 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -748,8 +748,8 @@ break Charter's invariants outright: | `charter status ` | result, cost, tools called, approvals, why it stopped | | `charter audit --instance ` | every governance decision recorded | | `charter pending --instance ` | the open approval or input gate | -| `charter approve [--reason]` | resolve to workflow + approval id, decide | -| `charter reject [--reason]` | same | +| `charter approve [--actor] [--reason]` | resolve to workflow + approval id, decide | +| `charter reject [--actor] [--reason]` | same | | `charter answer ` | respond to an `ask_human` gate | | `charter pause --instance [--now]` | hold it; prints the suspension id | | `charter resume --instance --suspension ` | release that hold | diff --git a/README.md b/README.md index 7027b9d..4f24526 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ Charter stops the task and shows a person the call it wants to make and the reasoning behind it: ```bash -charter approve apr_01J8Z --reason "third dispute this month" +charter approve apr_01J8Z --actor dana --reason "third dispute this month" ``` Nothing waits in your terminal. The task ends at the gate and resumes when someone diff --git a/charter/cli.py b/charter/cli.py index c21771f..d42d710 100644 --- a/charter/cli.py +++ b/charter/cli.py @@ -1118,9 +1118,9 @@ async def go(): g = wf.pending_approval ui.gate(agent, "approval", g.approval_id, _gate_body(g), fields=_gate_fields(g, "approval"), actions=[ f"charter approve {g.approval_id} --agent {agent} " - f"--instance {short(wf.id)} --reason '...'", + f"--instance {short(wf.id)} --actor --reason '...'", f"charter reject {g.approval_id} --agent {agent} " - f"--instance {short(wf.id)} --reason '...'", + f"--instance {short(wf.id)} --actor --reason '...'", ], timeout=_when(g.timeout_at)) elif wf.pending_input: g = wf.pending_input @@ -1540,9 +1540,9 @@ async def go(): g = wf.pending_approval ui.gate(agent, "approval", g.approval_id, _gate_body(g), fields=_gate_fields(g, "approval"), actions=[ f"charter approve {g.approval_id} --agent {agent} " - f"--instance {short(wf.id)} --reason '...'", + f"--instance {short(wf.id)} --actor --reason '...'", f"charter reject {g.approval_id} --agent {agent} " - f"--instance {short(wf.id)} --reason '...'", + f"--instance {short(wf.id)} --actor --reason '...'", ], timeout=_when(g.timeout_at)) elif wf.pending_input: g = wf.pending_input diff --git a/examples/README.md b/examples/README.md index 74a4576..6a1786c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -48,15 +48,15 @@ From a terminal instead: That prints the call it wants to make and the two commands that answer it. Both take a reason, and the reason is not paperwork: it is handed to the agent. - charter approve --agent refund-triage --instance --reason '...' - charter reject --agent refund-triage --instance --reason '...' + charter approve --agent refund-triage --instance --actor --reason '...' + charter reject --agent refund-triage --instance --actor --reason '...' Approve, and the refund goes through and the task finishes with what it did. The console shows it pick back up, and the audit there records who decided and why. Reject with a reason that says what was wrong, and the agent works from it: - charter reject --agent refund-triage --instance \ + charter reject --agent refund-triage --instance --actor \ --reason 'only half of this is ours. the second charge was authorised by the customer on a different order, so refund 24.00, not 48.00'