Run steps, token markers and cost reporting
How the host turns the brain's tool calls into AMC steps, reports tokens and cost exactly once, and makes a failed run end as failed.
Foundry reports no usage for hosted agents: the run-level usage block arrives all zeros, and nothing shows between response.created and the end. So the shell emits progress and spend as informational `function_call` items with call_id equal to name. AMC treats exactly that shape as a step.
Steps in order
| Step | Emitted by | When | Carries |
|---|---|---|---|
Starting <agent> | agent._serve | When the request is accepted, before any model call | Request fields, agent version, model, ceilings |
One per phase (Loading the skill, …) | steps.StepReporter | When the phase has been idle for two assistant messages, or at turn end | Tool-call count, elapsed time, up to 8 descriptors, model and that phase's tokens |
One per produced item (Item: <title>) | steps.StepReporter | When a tool result lands and a watched file changed | The entry's own fields, copied |
Host steps (Publishing the output, Dry run: nothing published, Output ready) | runner._postprocess | After the turn | Records; failure_reason when refused |
Trailing <agent>:<model> markers, then the failure marker | agent._markers | Last | Residual tokens, cost, run totals |
Titles are stable across runs and unique within one; a repeat becomes Title (2). Run-specific facts go in delivered, never in the title.
Phases
A phase is a fact about a tool call, never a model's opinion. PHASE_RULES maps (tool, field, regex) to a phase; the first match wins, so order rules from specific to generic. Calls that match nothing fold into the most recent phase.
PHASE_TITLES = {
"loading": "Loading the skill",
"inputs": "Reading invoices",
"produce": "Writing the summary",
"verify": "Verifying totals",
}
PHASE_RULES = [
# keep the two loading rules from the template
("Skill", "skill", re.compile(r"^(?:.*[:/])?invoice-summarizer$"), "loading", "Skill invoice-summarizer"),
("Read", "file_path", re.compile(r"/\.claude/skills/invoice-summarizer/"), "loading", "read {basename}"),
("Read", "file_path", re.compile(r"/invoices/"), "inputs", "read {basename}"),
("Bash", "command", re.compile(r"verify_totals\.py"), "verify", "{basename}"),
("Write", "file_path", re.compile(r"\.(json|md|html)$"), "produce", "write {basename}"),
]- Fields:
url(host and path, lower-cased),query,command,file_path, or*for the whole input as JSON. - Descriptor templates can use
{value},{compact_url}and{basename}. - Skill entry is matched on the skill name, allowing a
plugin:or directory prefix. Any installed skill can be loaded by the Skill tool, so "a Skill call happened" isn't evidence this method was entered. - Give each gate (verify, lint, render) its own phase. Those are the steps a reader wants to see pass.
Idle close
Closing a phase the moment another phase's call arrives fragments the list into dozens of numbered repeats, because the model spreads one message's calls over several stages. So a phase closes after PHASE_IDLE_MESSAGES = 2 assistant messages without a call in it. Keep 2.
Test it with a one-message gap. A two-message gap closes the phase either way, so it can't tell correct accounting from an off-by-one. In this example from the skill's own guidance, lint maps to a phase titled Linting the body:
run(["lint", "plan", "lint"]) # no "(2)": the phase survived the single gap
run(["lint", "plan", "queue", "lint"]) # "Linting the body (2)": closed after twoItem steps
ARTIFACT_WATCHES names a data file the skill writes in its run directory, the list inside it, the key that detects new entries, and the fields to show. The brain composes; the host transcribes.
ARTIFACT_WATCHES = [
{"file": "invoices.json", "list": "items", "key": "id", "title": "vendor",
"title_prefix": "Item: ", "body": ["invoice_no", "date", "amount"]},
]file must be a single file name directly in the run directory. Optional revision_field and revision_prefix report a field's revisions as their own steps, marked (revised). Leave the list empty when the skill produces one opaque output.
Tokens, exactly once
Each phase step carries the tokens accrued since the previous close, for the model with the most new output, and marks them attributed. The trailing marker for each model then carries only totals − attributed, never negative: WebFetch's Haiku summaries, the closing narration, anything no step claimed. Summing every step plus the trailing markers gives the run total once.
- `input_tokens` is cache-inclusive: uncached + cache read + cache creation.
cached_input_tokensis the cache-read subset. AMC pricesinput − cachedat the input rate andcachedat the cached rate. Report only the uncached remainder and a warm-cache run is under-priced by orders of magnitude. - Every step names its `model`, even at zero tokens. A step without one is priced at AMC's generic default rate: wrong money, no error.
- Never put run totals back into `input_tokens` on the trailing markers while steps carry tokens. AMC would bill the run twice.
- `cost_usd` follows the same rule. AMC prefers a reported cost over pricing tokens, so the trailing marker carries the model's exact
cost_usdonly when no step claimed that model's tokens. Otherwise the figure rides asrun_model_cost_usd, which AMC ignores. - `STEP_TOKENS=off` moves every token onto the trailing markers. Use it for a consumer that reads only the stored background response, where mid-run steps aren't persisted.
The trailing marker
{
"type": "function_call",
"call_id": "invoice-summarizer-agent:<sonnet-deployment>",
"name": "invoice-summarizer-agent:<sonnet-deployment>",
"arguments": {
"delivered": "run complete: 9 steps reported, $1.84, 41 turns, 12 min",
"input_tokens": 1820, "output_tokens": 412, "total_tokens": 2232,
"cached_input_tokens": 1500,
"model": "<sonnet-deployment>",
"run_model_cost_usd": 1.79, "run_cost_usd": 1.84,
"run_input_tokens": 912340, "run_output_tokens": 38110,
"run_cached_input_tokens": 851200, "run_turns": 41,
"run_duration_s": 734, "steps_reported": 9
}
}Values are illustrative. One marker per model, each with its own call_id: markers that share a call_id merge into one item with invalid JSON arguments. Never emit an aggregate marker alongside the per-model ones.
Token figures come from the SDK's ResultMessage.model_usage, which is per deployment, covers the whole tree (sub-agents and WebFetch's summarizer included), and is camelCase (inputTokens, cacheReadInputTokens, costUSD). Per-step tokens come from AssistantMessage.usage, which repeats on every content-block frame of a message: key it by message id and keep the last.
activity_log
With EMIT_STEP_ACTIVITY_LOG=true, the run's transcript (thinking, tool calls and results, bounded but not redacted) rides on exactly one marker: the model with the most output tokens. AMC renders it as collapsible detail. Never on every marker, never on the failure marker.
A failed run must end as failed
AMC derives run status from the Responses lifecycle frame: response.completed is completed, response.failed or an error event is failed. A marker with failure_reason fails only that step. An agent whose publish is refused, but whose turn ends normally, shows as a green run with one red step.
So _run_stream yields the envelope and every marker, then raises:
failed = bool(error) or bool((result or {}).get("is_error")) # the FLAGS, never the text
if failed:
reason = str(error or (result or {}).get("error") or _NO_REASON)
raise RunFailed(reason[:2000])- The hosting adapter catches it and emits
response.failedwitherror.messageset to the reason. Items already streamed stay on the response, so AMC gets the status, the reason and the spend. - Gate on the flags, not the reason text. An SDK error frame can arrive with an empty reason.
- Never raise before the markers, or the spend report is lost. Raise from inside the generator's
tryso the cancel-on-disconnectfinallystill runs. - Waiting on a person is not a failure. A pending question completes the response normally.
The failure marker
{
"type": "function_call",
"call_id": "invoice-summarizer-agent",
"name": "invoice-summarizer-agent",
"arguments": {"delivered": "", "failure_reason": "refused to publish: ... verify", "model": "<sonnet-deployment>"}
}Exactly one, named with the plain agent name, never with tokens or cost_usd. Emit it for a shell-level refusal (no usage markers exist) and for a runner-level error (usage markers accompany it).
The non-streaming path can't do this
If _run_once raised, the adapter would emit response.failed with empty output and the envelope and markers would be lost. So a background request without stream always stores a completed response, and its envelope's status carries the verdict.
| Path | Used by | Run verdict |
|---|---|---|
Streaming (stream: true) | AMC, a person watching | The lifecycle frame, and the envelope |
Background without stream | A raw scheduler polling by id | The envelope's status only |
Completed with issues
A failed step doesn't fail the run, and AMC never works out from failed steps that a run was only partly done. Only an explicit run.outcome marker counts. So the shell has three endings:
| Ending | When | Envelope `status` | On the stream | AMC shows |
|---|---|---|---|---|
| ok | Every step ran, or every failure was classed ignore | ok | response.completed | Completed |
| Completed with issues | A step classed partial failed, and nothing fatal did | partial, with output.partial_reasons | One run.outcome marker, then response.completed | Completed with issues, with the reason |
| failed | Anything fatal: a ceiling, a refused publish, a bad request, a host step that isn't classed | failed | The failure marker, then RunFailed and response.failed | Failed |
STEP_FAILURE_POLICY
runner.STEP_FAILURE_POLICY maps a host step's title to what its failure means for the run. You fill it in when you build the agent:
STEP_FAILURE_POLICY: dict[str, str] = {
"Notifying the channel": "partial",
"Refreshing the archive index": "ignore",
}| Value | The run |
|---|---|
fatal | Fails. This is the default: a title that isn't listed, or any value other than partial or ignore, is fatal. |
partial | Completes with issues. The step's title and reason are added to the run's reasons. |
ignore | Stays clean. The step still shows red. |
- A failing host action reports through
runner.host_step_failed(result, reporter, title, delivered, reason), which shows the red step and applies the policy. runner.note_partial(result, reason)records a shortfall the host can compute from the skill's own output, such as a count in its data file.- Publishing the deliverable is always fatal, so Publishing the output is never listed.
- The shell joins the reasons into one marker, capped at 2000 characters. It sends none on a failed run or a paused one. A failure after it still wins.
- The non-streaming path carries the same verdict in the envelope (
status: "partial"), and the marker is in its output.
The run.outcome marker
Content.from_function_call(
call_id="run.outcome", name="run.outcome",
arguments={"outcome": "partial", "reason": "Notifying the channel: Slack returned 503"},
informational_only=True,
)- Reserved name
run.outcome, withcall_idequal toname. AMC reads it from the item'sdoneframe and never lists it as a step. It carries no tokens, nomodeland nodelivered. outcomemust be"partial", the only value AMC acts on. Any other value is ignored and keeps an earlierpartial.reasonsays what wasn't done, in words the user can act on, up to 2000 characters. AMC shows it as the run's reason.- Send it any time before
response.completed. The lastpartialwins. A marker after the run has ended is ignored. - Failed wins. If the run ends
response.failed, AMC ignores the marker.
The envelope
The last text item, same shape on every path:
{"status": "ok" | "partial" | "failed" | "awaiting_human",
"error": null,
"request": {"mode": "run", "target_date": "2026-10-16", "dry_run": false},
"output": {"output_path", "output_files", "output_bytes", "gate_evidence", "skills_bypassed",
"published", "preflight", "steps", "pending_request", "hitl_paused", "partial_reasons"},
"metrics": {"turns", "cost_usd", "input_tokens", "output_tokens", "duration_ms", "model_usage",
"step_tokens_attributed", "recovered_errors", "timed_out"}}gate_evidencecarries<gate>_okfrom the host's own run of each gate, plus<gate>_run_by_brainas an observation. A failing gate adds<gate>_output.skills_bypassednon-null means the skill was never entered and the output came from improvisation. It is reported, not failed.
Confidential mode
EMIT_STEP_ACTIVITY_LOG=false is the single switch for confidential task data. It withholds the transcript, emits phase steps with counts, timings and tokens but no descriptors, turns item steps into Item: item 3 with no content, echoes request field names only, masks file and publish-record names in the envelope with ***, keeps the model's narration in the container, and replaces model-authored error text and request-derived refusal text with a fixed sentence (the detail stays in the container log).
Rename the run
An agent can rename its own run in AMC with a marker named run.renamed whose call_id is also run.renamed, carrying {"name": "…"}. Or add run_name to any step's arguments, such as the Starting … step. The last non-empty name wins, a blank name is ignored, names are trimmed and capped at 80 characters, and a rename after the run has ended is ignored.
Related
Last updated 2026-10-21