diff --git a/DESIGN.md b/DESIGN.md index df19eb2..2af5773 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`. @@ -434,6 +437,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 @@ -678,6 +688,9 @@ 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. +`set_version` takes no window: 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. @@ -735,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 7cbb17c..4f24526 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). @@ -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 @@ -288,10 +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/): 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 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 c1f8ca4..d42d710 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 @@ -470,8 +474,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: @@ -638,7 +642,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, @@ -690,6 +695,28 @@ 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 _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" @@ -710,71 +737,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) @@ -787,27 +775,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: @@ -823,6 +791,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") @@ -962,19 +946,38 @@ 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. 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 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(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()) @@ -1072,8 +1075,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") @@ -1086,45 +1088,45 @@ 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), + # 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), + ("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, [ - f"charter approve {g.approval_id} --agent {agent} --reason '...'", - f"charter reject {g.approval_id} --agent {agent} --reason '...'", + 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)} --actor --reason '...'", + f"charter reject {g.approval_id} --agent {agent} " + f"--instance {short(wf.id)} --actor --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}"], + 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)) asyncio.run(go()) @@ -1161,8 +1163,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}, " @@ -1190,15 +1190,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()) @@ -1246,20 +1252,61 @@ 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 _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 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]]: + """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)) + # 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))) + if until := getattr(g, "timeout_at", None): + rows.append(("expires", _stamp(until))) + return rows def _stamp(ts) -> str: @@ -1269,6 +1316,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) @@ -1278,13 +1344,25 @@ 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 def _one_limit(d: dict) -> str: - n = next((d[k] for k in ("maxCalls", "max_calls", "maxFailures", "max_failures") + """One entry of a limit list. Capability limits name a capability, not a tool, + 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", + "maxProposals", "max_proposals") if d.get(k) is not None), "?") - return f"{d.get('tool')}={n}" + return f"{what}={_num(n)}" def _took(started, finished) -> str: @@ -1366,7 +1444,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) @@ -1380,9 +1458,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 @@ -1393,6 +1476,16 @@ 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(_policy_rows(dict(policy)), indent=" ") + if info.invoke_context: given = {k: v for k, v in info.invoke_context.items() if not k.startswith("_")} if given: @@ -1445,14 +1538,17 @@ 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 '...'", + 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)} --actor --reason '...'", + f"charter reject {g.approval_id} --agent {agent} " + f"--instance {short(wf.id)} --actor --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}", + 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)) else: ui.dim(f"{agent}: nothing waiting ({wf.lifecycle_state.value})") @@ -1679,8 +1775,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={_enum_name(getattr(e, 'metric', ''))} " + f"action={_enum_name(getattr(e, 'action', ''))}") asyncio.run(go()) @@ -1982,7 +2079,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/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/config/lifecycle.py b/charter/config/lifecycle.py index 627958e..4ead50b 100644 --- a/charter/config/lifecycle.py +++ b/charter/config/lifecycle.py @@ -72,7 +72,7 @@ class Pause(Base): 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.") seconds: int = Field(gt=0) 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/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/mcp/client.py b/charter/mcp/client.py index 35d1bbe..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,6 +343,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) and server.justifies(name): + tool = _explained(tool) out.append(_bounded(tool, getattr(self, "_tool_seconds", 0.0))) return out @@ -360,6 +366,50 @@ async def __aexit__(self, *exc) -> None: await self.aclose() +JUSTIFICATION = "justification" + + +def _explained(tool): + """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 + 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 JUSTIFICATION in props: + return tool + 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 []) + [JUSTIFICATION] + tool.args_schema = schema + + if tool.coroutine: + inner = tool.coroutine + + async def run(*args, **kwargs): + kwargs.pop(JUSTIFICATION, None) + return await inner(*args, **kwargs) + + tool.coroutine = run + elif tool.func: + inner_fn = tool.func + + def run_sync(*args, **kwargs): + kwargs.pop(JUSTIFICATION, 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/policy.py b/charter/policy.py index 3e351ac..7ec9ccf 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 @@ -120,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 {} @@ -135,6 +153,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. @@ -179,8 +203,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/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/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/charter/worker.py b/charter/worker.py index d0da5d5..62988c9 100644 --- a/charter/worker.py +++ b/charter/worker.py @@ -211,16 +211,39 @@ 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 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 holds policy for " + "this agent — running on default limits until " + "`charter apply` sets some", 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: @@ -230,7 +253,7 @@ async def _bundle_for(self, cp: ControlPlaneClient, 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: @@ -242,20 +265,10 @@ async def _bundle_for(self, cp: ControlPlaneClient, 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) - 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/charter/workflows/loop.py b/charter/workflows/loop.py index 0d26ee3..81e1a87 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(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.") elif verdict == "answer": decision = respond(_answer_text(ctx.input_answer)) elif verdict == "waited": @@ -661,13 +672,31 @@ 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(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. + 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"), 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: @@ -743,6 +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, ctx, tool: str) -> int | None: + """How many times this tool may be proposed in one task, if capped. + + 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: + caps = charter_policy.proposal_caps(ctx.agent_governor(self.cfg.name).policy) + except Exception: # noqa: BLE001 + 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. @@ -785,7 +828,14 @@ 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 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: diff --git a/demo/leads/README.md b/demo/leads/README.md deleted file mode 100644 index c0e2a57..0000000 --- a/demo/leads/README.md +++ /dev/null @@ -1,122 +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 - 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 -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 run -the approval console in 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 - -And the inbox in a third, 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/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 deleted file mode 100644 index 0624091..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 -`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 -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..6a1786c --- /dev/null +++ b/examples/README.md @@ -0,0 +1,133 @@ +# Examples + +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, and the agent pauses itself if too many are + turned down. + ticket-summarizer reads every open ticket and reports what needs attention. + Runs unattended on v2, and rolls itself back to v1 when + it spends too much. + +## Running them + +You need a control plane and the environment from the +[Quickstart](../README.md#quickstart), plus `ANTHROPIC_API_KEY`. + +Start the support server. It serves MCP over HTTP on port 8931, and both agents +reach it by URL: + + python examples/support_server.py + +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 . + +`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. 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: + + 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 --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 --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' + +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 `runtime.yaml` we applied lets one refund go through per task, and lets the +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 three rejections across the +last three runs. Keep rejecting, on this ticket or another, until it stops: + + charter describe refund-triage --instance + + refund-triage + version v1 + status paused + activity active + +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. + +Further runs are refused until you resume it: + + charter resume refund-triage --instance + +## Rolling a version back + +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 } } + +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 +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/refund-triage/lifecycle.yaml b/examples/refund-triage/lifecycle.yaml index 6b1f8a7..c0732e5 100644 --- a/examples/refund-triage/lifecycle.yaml +++ b/examples/refund-triage/lifecycle.yaml @@ -4,14 +4,7 @@ kind: LifecyclePolicy agent: refund-triage rules: - - when: { metric: num_failures, threshold: 2 } - then: { pause: { window: 5 } } - - - when: { metric: cost, threshold: 5.00 } - then: { cooldown: { window: 20, seconds: 300 } } - + # If 3 refund attempts are rejected in the last 3 runs, stay paused until someone + # runs `charter resume`. - when: { metric: approval_rejections, threshold: 3 } - then: { set_version: { target: 1 } } - - - when: { metric: tool_failures, threshold: 3, tool: stripe__create_refund } - then: { pause: { window: 10 } } + then: { pause: { window: 3 } } diff --git a/examples/refund-triage/runtime.yaml b/examples/refund-triage/runtime.yaml index fc7e167..f6893ba 100644 --- a/examples/refund-triage/runtime.yaml +++ b/examples/refund-triage/runtime.yaml @@ -8,12 +8,15 @@ per_run: max_cost_usd: 0.30 max_llm_calls: 40 tool_call_limits: - - tool: stripe__get_charge + # 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: 2 + - tool: support__get_charge max_calls: 5 - - tool: zendesk__search_tickets + - 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 @@ -22,10 +25,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 04e1553..a835650 100644 --- a/examples/refund-triage/v1.yaml +++ b/examples/refund-triage/v1.yaml @@ -12,16 +12,22 @@ 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. + 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 + an amount that was just refused. If the reason means no refund is right, say so + and stop. inputs: ticket_id: type: string required: true - description: Zendesk ticket to resolve. + description: Ticket to resolve. T-1041 to T-1044 exist. max_refund_usd: type: number default: 100 @@ -32,26 +38,17 @@ inputs: enum: [low, normal, urgent] mcp: - - name: zendesk - command: npx - args: ["-y", "@zendesk/mcp"] - env: [ZENDESK_SUBDOMAIN, ZENDESK_API_TOKEN] + - name: support + url: http://localhost:8931/mcp 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 + # The model must state its case, and the approver reads it. + justify: true response_format: resolution: @@ -60,6 +57,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/refund-triage/v1/skills/refund-policy/SKILL.md b/examples/refund-triage/v1/skills/refund-policy/SKILL.md new file mode 100644 index 0000000..a5cc537 --- /dev/null +++ b/examples/refund-triage/v1/skills/refund-policy/SKILL.md @@ -0,0 +1,33 @@ +--- +name: refund-policy +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 + +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/support_server.py b/examples/support_server.py new file mode 100644 index 0000000..c0f6a35 --- /dev/null +++ b/examples/support_server.py @@ -0,0 +1,98 @@ +"""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/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. 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 +from mcp.types import ToolAnnotations + +READ_ONLY = ToolAnnotations(readOnlyHint=True) +MUTATES = ToolAnnotations(readOnlyHint=False, destructiveHint=False) + +mcp = FastMCP("support", host="127.0.0.1", port=8931) + +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. 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(transport="streamable-http") 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 } } diff --git a/examples/ticket-summarizer/runtime.yaml b/examples/ticket-summarizer/runtime.yaml index 86f9ae7..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: zendesk__get_ticket + - tool: support__get_ticket max_calls: 25 diff --git a/examples/ticket-summarizer/v1.yaml b/examples/ticket-summarizer/v1.yaml index e682b2e..ca0a805 100644 --- a/examples/ticket-summarizer/v1.yaml +++ b/examples/ticket-summarizer/v1.yaml @@ -12,10 +12,8 @@ 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: support + 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 088a57c..5e5b8fe 100644 --- a/examples/ticket-summarizer/v2.yaml +++ b/examples/ticket-summarizer/v2.yaml @@ -5,20 +5,18 @@ 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: zendesk - command: npx - args: ["-y", "@zendesk/mcp"] - env: [ZENDESK_SUBDOMAIN, ZENDESK_API_TOKEN] + - name: support + url: http://localhost:8931/mcp tools: - tool: search_tickets on_failure: fail diff --git a/examples/worker.yaml b/examples/worker.yaml index 4cda6eb..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} @@ -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/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 diff --git a/tests/e2e/test_lifecycle.py b/tests/e2e/test_lifecycle.py index 2b41431..56120d4 100644 --- a/tests/e2e/test_lifecycle.py +++ b/tests/e2e/test_lifecycle.py @@ -144,3 +144,67 @@ 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, 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 + 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": agent, "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, "refund-demo", + {"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" diff --git a/tests/test_agent_config.py b/tests/test_agent_config.py index fbf89b9..b464041 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 == ["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 def test_invoke_mode_is_derived(): @@ -62,20 +62,21 @@ 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"][1]["url"] = "http://mcp.stripe__com" + raw["mcp"][0]["url"] = "http://mcp.example.com" with pytest.raises(ValidationError, match="https"): AgentConfig.model_validate(raw) @@ -83,7 +84,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) @@ -299,3 +300,58 @@ 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 "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 + 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", 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("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_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_cli.py b/tests/test_cli.py index c439bba..373c298 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)) @@ -159,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): @@ -227,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 @@ -701,3 +709,77 @@ 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}" + + +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 + + +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 diff --git a/tests/test_compile.py b/tests/test_compile.py index e65cc65..1260cfc 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,25 @@ 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 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) @@ -44,8 +64,9 @@ 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, + "support__create_refund": 1, + "support__get_charge": 5, + "support__get_ticket": 10, } @@ -65,7 +86,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 @@ -84,9 +112,12 @@ 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) - assert rule.tool == "stripe__create_refund" + 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 @@ -115,8 +146,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 @@ -148,6 +196,21 @@ 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 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(): """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, @@ -192,3 +255,37 @@ 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 + + +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) == declared_proposal_caps() + assert policy.timeouts(off_the_wire) == policy.timeouts(compiled) 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..c9ac7ef 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("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": ["net__search_people"]}]) - tools = fake_tools("net__search_people", "net__send_message") + "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"]] == ["net__search_people"] + 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": ["net__nonexistent"]}]) + "tools": ["support__nonexistent"]}]) def test_general_purpose_cannot_be_redeclared(): diff --git a/tests/test_loader.py b/tests/test_loader.py index 56ceab7..7a4fb64 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(): @@ -65,21 +66,26 @@ 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"] = "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"] = "stripe__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") @@ -354,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 8c8fd11..3569f41 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) == {"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": "stripe__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 "stripe__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"] == "stripe__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_ticket": 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="stripe__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": "stripe__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 "stripe__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 "stripe__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 @@ -902,3 +902,68 @@ 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_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, + "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 + + +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 _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 = 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 = 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 = 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) + + +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" 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 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