Per-role cost in a LangChain coding agent
A triager, parallel file readers, an editor and a reviewer in LangChain 1.x on GPT-4.1, then per-agent cost and a blocking budget on the role that fans out.
- Framework
- LangChain 1.x
- Provider
- openai
- Python
- 3.11+
- Capsera
- pip install capsera

This builds a coding agent in LangChain 1.x against OpenAI: a triager picks
which files to read from a bug report, screeners read those files in parallel,
an editor rewrites the one file at fault, and a reviewer approves the patch or
sends it back once. One fix is four model calls at the floor and nine at the
cap, and where in that range a given report lands is decided by two models
rather than by your code. The last third of this page adds Capsera: one init()
call to capture every request, four decorator lines to say which role made it,
and a blocking budget on the role that fans out.
What you will build
fixer.py, a single-file agent with four roles and a supervisor:
- triager — one call. Reads an index of the repository and the report, names
at most
MAX_CANDIDATESfiles worth opening. - screener — one call per named file, run in parallel. Reads one file and
answers
SUSPECTorCLEAR. The multiplier. - editor — one call, on the stronger model. Rewrites one file in full.
- reviewer — one call. Approves the patch or names the single change needed,
which buys at most
MAX_REVISIONSmore editor passes.
The agent proposes; it never writes to your working tree. The editor returns
file contents and the diff is computed in Python with difflib, so what you
read at the end is a real unified diff of a change that was never applied.
Each role is its own function. That matters later — it is the seam the instrumentation attaches to — and it is the better shape regardless, because a supervisor that inlines four model calls has nothing you can name, budget, or move to a different model.
Written against LangChain 1.x with langchain-openai, calling the OpenAI API
directly rather than through a gateway. Models: gpt-4.1-mini for the triager,
the screeners and the reviewer, gpt-4.1 for the editor.
Prerequisites
Python 3.11 or newer.
pip install "langchain>=1.0,<2" langchain-openai
The major is pinned so this page stays true as LangChain moves.
langchain-openai is left unpinned on purpose: install the one that matches
your langchain major.
You need an OpenAI API key. ChatOpenAI reads OPENAI_API_KEY from the
environment, so export it and no code has to touch it:
export OPENAI_API_KEY=...
Step 1 — Fix the repository the agent may read
Start with the repository, not the agent. A coding agent pointed at a real checkout has an unbounded number of calls in it, because every tool call it makes to look around is a call you pay for. An agent handed a fixed snapshot has a call count you can multiply out before you run anything, which is what makes the rest of this page measurable.
The snapshot below is a small service with a real bug in it: retries that sleep after the final attempt, wrapped in retries, so one transient failure spends longer than the worker's deadline allows. Two files are implicated, one file looks implicated and is not, and a test states the expected behaviour.
"""fixer.py — the repository snapshot a coding agent is allowed to read."""
SNAPSHOT = {
"app/config.py": (
"RETRY_ATTEMPTS = 3\n"
"RETRY_BASE_DELAY = 0.5\n"
"JOB_DEADLINE_SECONDS = 5.0\n"
),
"app/retry.py": (
"import time\n"
"\n"
"from app.config import RETRY_ATTEMPTS, RETRY_BASE_DELAY\n"
"\n"
"\n"
"class TransientError(Exception):\n"
" pass\n"
"\n"
"\n"
"def with_retries(fn, attempts=RETRY_ATTEMPTS):\n"
" last = None\n"
" for i in range(attempts):\n"
" try:\n"
" return fn()\n"
" except TransientError as exc:\n"
" last = exc\n"
" time.sleep(RETRY_BASE_DELAY * 2**i)\n"
" raise last\n"
),
"app/client.py": (
"from app.retry import with_retries\n"
"\n"
"\n"
"def post_json(session, url, body):\n"
" return with_retries(lambda: session.post(url, json=body, timeout=2.0))\n"
"\n"
"\n"
"def submit(session, job):\n"
" # belt and braces: retry the whole submission as well\n"
" return with_retries(lambda: post_json(session, '/v1/jobs', job))\n"
),
"app/queue_worker.py": (
"import time\n"
"\n"
"from app.client import submit\n"
"from app.config import JOB_DEADLINE_SECONDS\n"
"\n"
"\n"
"def run_job(session, job):\n"
" started = time.monotonic()\n"
" try:\n"
" return submit(session, job)\n"
" finally:\n"
" if time.monotonic() - started > JOB_DEADLINE_SECONDS:\n"
" raise TimeoutError('job %s exceeded the deadline' % job['id'])\n"
),
"app/metrics.py": (
"RETRIES = {}\n"
"\n"
"\n"
"def record_retry(name):\n"
" RETRIES[name] = RETRIES.get(name, 0) + 1\n"
"\n"
"\n"
"def snapshot():\n"
" return dict(RETRIES)\n"
),
"tests/test_retry.py": (
"import time\n"
"\n"
"from app.retry import TransientError, with_retries\n"
"\n"
"\n"
"def test_gives_up_without_a_trailing_sleep():\n"
" calls = []\n"
"\n"
" def always_fails():\n"
" calls.append(1)\n"
" raise TransientError('boom')\n"
"\n"
" started = time.monotonic()\n"
" try:\n"
" with_retries(always_fails, attempts=2)\n"
" except TransientError:\n"
" pass\n"
" assert len(calls) == 2\n"
" assert time.monotonic() - started < 1.0\n"
),
}
ISSUE = (
"Jobs that hit one transient failure never come back. The worker raises "
"the deadline error and the job is marked failed instead of retried, and "
"tests/test_retry.py fails on timing. It started after we added retries "
"in the client. Nothing in the logs says which layer gave up."
)
def file_index() -> str:
return "\n".join(
f"{path} ({len(source.splitlines())} lines)"
for path, source in SNAPSHOT.items()
)
def file_text(path: str) -> str:
return SNAPSHOT[path]
if __name__ == "__main__":
print(file_index())
file_index() is the cheap artefact that makes the expensive one avoidable. The
triager sees paths and line counts, not source, so the call that decides where
to look costs a fraction of the calls that do the looking. On a real repository
you would build the same index from git ls-files and keep it to one line per
file for the same reason.
Step 2 — Triage, with the fan-out bound in code
The triager decides how wide the agent goes. So the cap lives in Python, not in the prompt — a model asked for "at most four" will occasionally name six, and the slice is what makes that harmless.
# fragment
import re
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
FAST_MODEL = "gpt-4.1-mini"
MAX_CANDIDATES = 4
_BULLET = re.compile(r"^\s*(?:[-*•]|\d+[.)])\s*")
def _lines(text: str, limit: int) -> list[str]:
cleaned = (_BULLET.sub("", line).strip() for line in text.splitlines())
return [line for line in cleaned if line][:limit]
TRIAGE_PROMPT = ChatPromptTemplate.from_messages(
[
(
"system",
"You triage bug reports against a repository index. Name the files "
"that must be read to explain the report. One path per line, copied "
"exactly from the index, no numbering and no commentary. At most "
"{max_candidates} paths.",
),
("human", "Report:\n{report}\n\nRepository index:\n{index}"),
]
)
triager_llm = ChatOpenAI(model=FAST_MODEL, max_tokens=256, temperature=0)
triage_chain = TRIAGE_PROMPT | triager_llm | StrOutputParser()
def triage(report: str) -> list[str]:
raw = triage_chain.invoke(
{
"report": report,
"index": file_index(),
"max_candidates": MAX_CANDIDATES,
}
)
return [p for p in _lines(raw, MAX_CANDIDATES) if p in SNAPSHOT]
Lines rather than JSON. A one-path-per-line contract degrades into a shorter list when the model ignores you; a malformed JSON object degrades into an exception, and a retry on a parse failure is a call you pay for twice.
The if p in SNAPSHOT filter is not defensive tidiness. A model naming a file
that does not exist is the normal failure here — app/retries.py for
app/retry.py — and without the filter that path either raises a KeyError or,
worse, reaches a screening call about a file nobody can read.
Step 3 — Screen the candidates in parallel
Four sequential reads that do not depend on each other is four round trips of
latency for nothing. batch() runs them concurrently and max_concurrency
bounds how many are in flight.
# fragment
SCREEN_CONCURRENCY = 4
SCREEN_PROMPT = ChatPromptTemplate.from_messages(
[
(
"system",
"You decide whether one source file is implicated in a bug report. "
"Reply with exactly one line. If it is implicated, write SUSPECT, a "
"colon, and at most twenty words naming the construct at fault. If "
"it is not, write CLEAR and nothing else. Judge only the file you "
"are given and never guess at code you cannot see.",
),
("human", "Report:\n{report}\n\nFile {path}:\n{source}"),
]
)
screener_llm = ChatOpenAI(model=FAST_MODEL, max_tokens=96, temperature=0)
screen_chain = SCREEN_PROMPT | screener_llm | StrOutputParser()
def screen(report: str, paths: list[str]) -> list[dict[str, str]]:
payloads = [
{"report": report, "path": p, "source": file_text(p)} for p in paths
]
verdicts = screen_chain.batch(
payloads, config={"max_concurrency": SCREEN_CONCURRENCY}
)
return [
{"path": p, "verdict": v.strip()}
for p, v in zip(paths, verdicts, strict=True)
if v.strip().upper().startswith("SUSPECT")
]
One file per screening call, not the whole candidate set per call. That is what
keeps widening the search affordable: the prompt is a fixed instruction plus one
file, so raising MAX_CANDIDATES multiplies a small number instead of growing a
large one. It also means a CLEAR verdict is a judgement about a file the model
actually had in front of it.
max_tokens=96 on the screener is load-bearing rather than tidiness. Output
tokens are the expensive half of a call, this role runs once per candidate, and
the single line it is asked for fits easily. A screener allowed to write a
paragraph is the same search at several times the price, and it hands the editor
a longer prompt too.
zip(..., strict=True) because pairing paths to verdicts by position is only
safe if the lengths agree, and a silent truncation here would blame the wrong
file.
Step 4 — Draft the patch, and diff it in Python
Do not ask a model for a diff. Unified diff format needs correct hunk headers
and line counts, getting them wrong is invisible until git apply refuses the
patch, and a rejected patch is a re-run of the most expensive call on the page.
Ask for the file, and compute the diff yourself.
# fragment
import difflib
EDIT_MODEL = "gpt-4.1"
BEGIN = "<<<BEGIN FILE>>>"
END = "<<<END FILE>>>"
EDIT_PROMPT = ChatPromptTemplate.from_messages(
[
(
"system",
"You fix one Python file. Return the complete new contents of that "
"file between a line reading <<<BEGIN FILE>>> and a line reading "
"<<<END FILE>>>, with nothing outside those markers. Change as "
"little as possible: keep the existing style, imports and public "
"names. If the report cannot be fixed in this file, return the file "
"unchanged.",
),
(
"human",
"Report:\n{report}\n\nScreening notes:\n{notes}\n\n"
"File to change: {path}\n\nCurrent contents:\n{source}\n\n"
"Required change from review: {feedback}",
),
]
)
editor_llm = ChatOpenAI(model=EDIT_MODEL, max_tokens=2048, temperature=0)
edit_chain = EDIT_PROMPT | editor_llm | StrOutputParser()
def format_notes(findings: list[dict[str, str]]) -> str:
return "\n".join(f"{f['path']}: {f['verdict']}" for f in findings)
def _between(text: str) -> str:
if BEGIN in text and END in text:
return text.split(BEGIN, 1)[1].split(END, 1)[0].strip("\n")
return text.strip()
def draft(report: str, notes: str, path: str, feedback: str = "") -> str:
raw = edit_chain.invoke(
{
"report": report,
"notes": notes,
"path": path,
"source": file_text(path),
"feedback": feedback or "none",
}
)
return _between(raw)
def diff_for(path: str, new_source: str) -> str:
return "".join(
difflib.unified_diff(
file_text(path).splitlines(keepends=True),
new_source.splitlines(keepends=True),
fromfile=f"a/{path}",
tofile=f"b/{path}",
)
)
Explicit markers rather than a bare response, because a model told to return only
code will sometimes wrap it in a fenced block anyway, and _between is two lines
while a fence parser that survives fences inside the file is not. When the
markers are missing the whole response is used, so a reply that ignored the
instruction still produces a patch instead of an exception.
difflib.unified_diff also gives you a free check the model cannot fake: an
empty diff means the editor changed nothing. That is a cheap way to catch the
common non-answer — a polite explanation of the bug, correctly wrapped in
markers, with the file returned as it was.
The editor is the only role on the stronger model, because it is the only one whose output ends up in your repository. The other three produce input for another model. Whether that split is right is a judgement to revisit with real numbers rather than settle by guessing now.
Step 5 — Review, with the loop bounded
The reviewer is where a coding agent turns into a runaway loop if you let it. Edit, review, reject, edit again is a cycle with no natural end: the reviewer always has one more objection available, and every lap is one editor call on the expensive model plus one reviewer call. The bound belongs in Python, as a constant you can read, not in a prompt asking the reviewer to be reasonable.
# fragment
MAX_REVISIONS = 1
REVIEW_PROMPT = ChatPromptTemplate.from_messages(
[
(
"system",
"You review one patch against the report it claims to fix. Reply "
"with exactly one line. Write APPROVE if the patch fixes the "
"reported behaviour and changes nothing else. Otherwise write "
"REVISE, a colon, and one sentence naming the single change "
"required. Do not restate the patch and do not suggest "
"improvements that the report did not ask for.",
),
("human", "Report:\n{report}\n\nPatch:\n{diff}"),
]
)
reviewer_llm = ChatOpenAI(model=FAST_MODEL, max_tokens=128, temperature=0)
review_chain = REVIEW_PROMPT | reviewer_llm | StrOutputParser()
def review(report: str, diff: str) -> str:
return review_chain.invoke({"report": report, "diff": diff}).strip()
"Do not suggest improvements that the report did not ask for" is in the prompt
for the same reason MAX_REVISIONS is in the code. A reviewer that asks for
type hints on an unrelated function gets them, and you pay for a second editor
pass on the expensive model to buy a change nobody reported.
The reviewer reads the diff, not the whole file. The diff is the smallest thing that answers the question it was asked, and on a large file it is a fraction of the input tokens the editor just spent.
Step 6 — The supervisor
One function, and every branch the agent can take is visible in it.
# fragment
def fix_issue(report: str) -> dict[str, str]:
paths = triage(report)
if not paths:
raise RuntimeError("the triager named no file that exists in the index")
findings = screen(report, paths)
if not findings:
raise RuntimeError("screening implicated no file")
target = findings[0]["path"]
notes = format_notes(findings)
feedback = ""
# Runs at least once, so patch and verdict are always bound below.
for _ in range(MAX_REVISIONS + 1):
patch = diff_for(target, draft(report, notes, target, feedback))
if not patch:
raise RuntimeError(f"the editor returned {target} unchanged")
verdict = review(report, patch)
if verdict.upper().startswith("APPROVE"):
return {"path": target, "diff": patch, "review": verdict}
feedback = verdict
return {"path": target, "diff": patch, "review": verdict}
findings[0] picks the first implicated file in the triager's order, and the
editor changes exactly one file. A real fixer would rank the suspects, and
ranking is another model call; a multi-file patch is a different and much more
expensive program, because the editor's cost then scales with the number of
files it rewrites rather than being one call.
Count the calls: one triager, up to four screeners, then one or two editors and one or two reviewers. Four at the floor, nine at the cap. Both of those numbers come from your constants; nothing in your file decides where between them a given report lands.
The whole file
"""fixer.py — a coding agent: triage, screen, patch, review."""
import difflib
import re
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
FAST_MODEL = "gpt-4.1-mini"
EDIT_MODEL = "gpt-4.1"
MAX_CANDIDATES = 4
SCREEN_CONCURRENCY = 4
MAX_REVISIONS = 1
BEGIN = "<<<BEGIN FILE>>>"
END = "<<<END FILE>>>"
SNAPSHOT = {
"app/config.py": (
"RETRY_ATTEMPTS = 3\n"
"RETRY_BASE_DELAY = 0.5\n"
"JOB_DEADLINE_SECONDS = 5.0\n"
),
"app/retry.py": (
"import time\n"
"\n"
"from app.config import RETRY_ATTEMPTS, RETRY_BASE_DELAY\n"
"\n"
"\n"
"class TransientError(Exception):\n"
" pass\n"
"\n"
"\n"
"def with_retries(fn, attempts=RETRY_ATTEMPTS):\n"
" last = None\n"
" for i in range(attempts):\n"
" try:\n"
" return fn()\n"
" except TransientError as exc:\n"
" last = exc\n"
" time.sleep(RETRY_BASE_DELAY * 2**i)\n"
" raise last\n"
),
"app/client.py": (
"from app.retry import with_retries\n"
"\n"
"\n"
"def post_json(session, url, body):\n"
" return with_retries(lambda: session.post(url, json=body, timeout=2.0))\n"
"\n"
"\n"
"def submit(session, job):\n"
" # belt and braces: retry the whole submission as well\n"
" return with_retries(lambda: post_json(session, '/v1/jobs', job))\n"
),
"app/queue_worker.py": (
"import time\n"
"\n"
"from app.client import submit\n"
"from app.config import JOB_DEADLINE_SECONDS\n"
"\n"
"\n"
"def run_job(session, job):\n"
" started = time.monotonic()\n"
" try:\n"
" return submit(session, job)\n"
" finally:\n"
" if time.monotonic() - started > JOB_DEADLINE_SECONDS:\n"
" raise TimeoutError('job %s exceeded the deadline' % job['id'])\n"
),
"app/metrics.py": (
"RETRIES = {}\n"
"\n"
"\n"
"def record_retry(name):\n"
" RETRIES[name] = RETRIES.get(name, 0) + 1\n"
"\n"
"\n"
"def snapshot():\n"
" return dict(RETRIES)\n"
),
"tests/test_retry.py": (
"import time\n"
"\n"
"from app.retry import TransientError, with_retries\n"
"\n"
"\n"
"def test_gives_up_without_a_trailing_sleep():\n"
" calls = []\n"
"\n"
" def always_fails():\n"
" calls.append(1)\n"
" raise TransientError('boom')\n"
"\n"
" started = time.monotonic()\n"
" try:\n"
" with_retries(always_fails, attempts=2)\n"
" except TransientError:\n"
" pass\n"
" assert len(calls) == 2\n"
" assert time.monotonic() - started < 1.0\n"
),
}
ISSUE = (
"Jobs that hit one transient failure never come back. The worker raises "
"the deadline error and the job is marked failed instead of retried, and "
"tests/test_retry.py fails on timing. It started after we added retries "
"in the client. Nothing in the logs says which layer gave up."
)
_BULLET = re.compile(r"^\s*(?:[-*•]|\d+[.)])\s*")
TRIAGE_PROMPT = ChatPromptTemplate.from_messages(
[
(
"system",
"You triage bug reports against a repository index. Name the files "
"that must be read to explain the report. One path per line, copied "
"exactly from the index, no numbering and no commentary. At most "
"{max_candidates} paths.",
),
("human", "Report:\n{report}\n\nRepository index:\n{index}"),
]
)
SCREEN_PROMPT = ChatPromptTemplate.from_messages(
[
(
"system",
"You decide whether one source file is implicated in a bug report. "
"Reply with exactly one line. If it is implicated, write SUSPECT, a "
"colon, and at most twenty words naming the construct at fault. If "
"it is not, write CLEAR and nothing else. Judge only the file you "
"are given and never guess at code you cannot see.",
),
("human", "Report:\n{report}\n\nFile {path}:\n{source}"),
]
)
EDIT_PROMPT = ChatPromptTemplate.from_messages(
[
(
"system",
"You fix one Python file. Return the complete new contents of that "
"file between a line reading <<<BEGIN FILE>>> and a line reading "
"<<<END FILE>>>, with nothing outside those markers. Change as "
"little as possible: keep the existing style, imports and public "
"names. If the report cannot be fixed in this file, return the file "
"unchanged.",
),
(
"human",
"Report:\n{report}\n\nScreening notes:\n{notes}\n\n"
"File to change: {path}\n\nCurrent contents:\n{source}\n\n"
"Required change from review: {feedback}",
),
]
)
REVIEW_PROMPT = ChatPromptTemplate.from_messages(
[
(
"system",
"You review one patch against the report it claims to fix. Reply "
"with exactly one line. Write APPROVE if the patch fixes the "
"reported behaviour and changes nothing else. Otherwise write "
"REVISE, a colon, and one sentence naming the single change "
"required. Do not restate the patch and do not suggest "
"improvements that the report did not ask for.",
),
("human", "Report:\n{report}\n\nPatch:\n{diff}"),
]
)
triager_llm = ChatOpenAI(model=FAST_MODEL, max_tokens=256, temperature=0)
screener_llm = ChatOpenAI(model=FAST_MODEL, max_tokens=96, temperature=0)
editor_llm = ChatOpenAI(model=EDIT_MODEL, max_tokens=2048, temperature=0)
reviewer_llm = ChatOpenAI(model=FAST_MODEL, max_tokens=128, temperature=0)
triage_chain = TRIAGE_PROMPT | triager_llm | StrOutputParser()
screen_chain = SCREEN_PROMPT | screener_llm | StrOutputParser()
edit_chain = EDIT_PROMPT | editor_llm | StrOutputParser()
review_chain = REVIEW_PROMPT | reviewer_llm | StrOutputParser()
def file_index() -> str:
return "\n".join(
f"{path} ({len(source.splitlines())} lines)"
for path, source in SNAPSHOT.items()
)
def file_text(path: str) -> str:
return SNAPSHOT[path]
def _lines(text: str, limit: int) -> list[str]:
cleaned = (_BULLET.sub("", line).strip() for line in text.splitlines())
return [line for line in cleaned if line][:limit]
def _between(text: str) -> str:
if BEGIN in text and END in text:
return text.split(BEGIN, 1)[1].split(END, 1)[0].strip("\n")
return text.strip()
def format_notes(findings: list[dict[str, str]]) -> str:
return "\n".join(f"{f['path']}: {f['verdict']}" for f in findings)
def triage(report: str) -> list[str]:
raw = triage_chain.invoke(
{
"report": report,
"index": file_index(),
"max_candidates": MAX_CANDIDATES,
}
)
return [p for p in _lines(raw, MAX_CANDIDATES) if p in SNAPSHOT]
def screen(report: str, paths: list[str]) -> list[dict[str, str]]:
payloads = [
{"report": report, "path": p, "source": file_text(p)} for p in paths
]
verdicts = screen_chain.batch(
payloads, config={"max_concurrency": SCREEN_CONCURRENCY}
)
return [
{"path": p, "verdict": v.strip()}
for p, v in zip(paths, verdicts, strict=True)
if v.strip().upper().startswith("SUSPECT")
]
def draft(report: str, notes: str, path: str, feedback: str = "") -> str:
raw = edit_chain.invoke(
{
"report": report,
"notes": notes,
"path": path,
"source": file_text(path),
"feedback": feedback or "none",
}
)
return _between(raw)
def diff_for(path: str, new_source: str) -> str:
return "".join(
difflib.unified_diff(
file_text(path).splitlines(keepends=True),
new_source.splitlines(keepends=True),
fromfile=f"a/{path}",
tofile=f"b/{path}",
)
)
def review(report: str, diff: str) -> str:
return review_chain.invoke({"report": report, "diff": diff}).strip()
def fix_issue(report: str) -> dict[str, str]:
paths = triage(report)
if not paths:
raise RuntimeError("the triager named no file that exists in the index")
findings = screen(report, paths)
if not findings:
raise RuntimeError("screening implicated no file")
target = findings[0]["path"]
notes = format_notes(findings)
feedback = ""
# Runs at least once, so patch and verdict are always bound below.
for _ in range(MAX_REVISIONS + 1):
patch = diff_for(target, draft(report, notes, target, feedback))
if not patch:
raise RuntimeError(f"the editor returned {target} unchanged")
verdict = review(report, patch)
if verdict.upper().startswith("APPROVE"):
return {"path": target, "diff": patch, "review": verdict}
feedback = verdict
return {"path": target, "diff": patch, "review": verdict}
if __name__ == "__main__":
result = fix_issue(ISSUE)
print(result["diff"])
print(result["review"])
python fixer.py
You get a unified diff against one file and one line of review. Which file it
picks is the interesting part: app/retry.py if the screening notes lead with
the trailing sleep, app/client.py if they lead with the nested retry call.
Both are real causes of the reported behaviour and the one-file editor can only
fix one of them, which is the honest limit of an agent shaped like this.
The application is finished. Nothing below changes what it does.
Seeing what it cost
What you do not have is any idea which of the four roles spent the money. The provider's invoice has one line for the API key, and the four to nine calls behind it are indistinguishable — same key, same account, three of the four roles on the same model.
pip install capsera
At the top of fixer.py, above everything else:
# fragment
import os
import capsera
capsera.init(api_key=os.environ["CAPSERA_API_KEY"], endpoint="https://api.capsera.ai")
That is the capture step, and it is already complete. capsera.init() patches
the provider client that ChatOpenAI constructs underneath, so from that call
on every request the agent makes is recorded: the triager's, each screener's
inside batch(), every editor pass, every reviewer pass. The prompts are
untouched, the chains are untouched, batch() is untouched, and no traffic
changed route — the SDK runs inside your process rather than in front of it, so
there is no proxy, no base_url to swap and nothing new in the request path.
One addition for a script: a background thread ships events on an interval, so a process that exits immediately can exit before the queue drains.
# fragment
if __name__ == "__main__":
result = fix_issue(ISSUE)
print(result["diff"])
print(result["review"])
capsera.shutdown()
capsera.shutdown() flushes pending events and stops the worker. A long-running
service does not need it; a script, a pre-commit hook or a notebook cell does,
and omitting it is the usual reason a first run reports nothing.
Run it now and you have a total, a per-model split and token counts per call.
You also have every one of those events attributed to unknown, because nothing
has said which role made it. unknown is a real row, not a dropped one — an
unattributed cost is still a cost — but it is one row where you wanted four.
Name the roles
The calls are already captured. The decorator only says who:
# fragment
@capsera.agent("triager", team="repo-fixer", task_type="file-selection")
def triage(report: str) -> list[str]:
... # body unchanged
@capsera.agent("screener", team="repo-fixer", task_type="file-screening")
def screen(report: str, paths: list[str]) -> list[dict[str, str]]:
... # body unchanged
@capsera.agent("editor", team="repo-fixer", task_type="patch")
def draft(report: str, notes: str, path: str, feedback: str = "") -> str:
... # body unchanged
@capsera.agent("reviewer", team="repo-fixer", task_type="patch-review")
def review(report: str, diff: str) -> str:
... # body unchanged
Four added lines, and that is the entire diff. No function body changed, no call site changed, no model construction changed, no argument threaded through the supervisor. Every LLM call made while a decorated function is on the stack is attributed to that agent, including calls made by the functions it calls and by the framework it invokes.
The fan-out keeps its identity. screen does its work in
screen_chain.batch(...), which dispatches to worker threads rather than
calling on the thread you were on, and the screener scope is still what gets
recorded. Capsera holds the attribution stack in a ContextVar and LangChain's
batch() dispatches through a context-copying executor, so the scope the caller
was in is the scope the worker sees — verified empirically on LangChain 1.3 with
capsera 0.3.0. batch() also produces one event per underlying call rather than
one per batch, so four candidate files are four screener events
(coverage table).
The credit there belongs to LangChain, and it is worth knowing where the
guarantee stops. A ContextVar does not cross a thread boundary on its
own — it crosses when the code starting the thread copies the context, which
batch() does and a hand-rolled pool does not. Screen the files yourself with a
bare ThreadPoolExecutor and the same decorator records unknown instead of
screener, with no error to tell you. Measured on the same versions:
# fragment
import contextvars
from concurrent.futures import ThreadPoolExecutor
# Loses the scope: workers start with a fresh context.
with ThreadPoolExecutor(max_workers=SCREEN_CONCURRENCY) as pool:
verdicts = list(pool.map(screen_one, paths))
# Keeps it: hand each worker a copy of the caller's context.
ctx = contextvars.copy_context()
with ThreadPoolExecutor(max_workers=SCREEN_CONCURRENCY) as pool:
verdicts = list(pool.map(lambda p: ctx.run(screen_one, p), paths))
Async needs nothing extra — a ContextVar is snapshotted per task, so roles
awaited concurrently under asyncio.gather keep their own attribution.
Leave the supervisor undecorated. fix_issue makes no LLM call of its own;
every call inside it happens while one of the four roles is on the stack, and
the innermost scope is the one recorded. Decorating it would add a row that can
never have spend on it. For a per-fix total, use the team field — which is
what team="repo-fixer" on all four decorators is for.
Cost the second editor pass separately
The review loop is the part of this agent whose cost you cannot see from the
role rows alone. A run with two editor passes and a run with one both report as
editor, so the row grows and nothing in it says the growth came from patches
being sent back. capsera.tag() opens a sub-scope inside a function you have
already decorated, which is exactly this case:
# fragment
import capsera
@capsera.agent("editor", team="repo-fixer", task_type="patch")
def draft(report: str, notes: str, path: str, feedback: str = "") -> str:
if not feedback:
return _draft(report, notes, path, "")
# A revision is the same agent doing a different job. Cost it as one.
with capsera.tag("editor", team="repo-fixer", task_type="patch-revision"):
return _draft(report, notes, path, feedback)
_draft is the original body, moved down one level and otherwise unchanged. The
revision keeps the agent name, so a budget scoped to editor still counts every
pass, and it changes the task type, so the share of editor spend that came from
rework is a number you can read instead of a suspicion. Use
tag() for a genuine sub-scope like this one. It is the wrong tool for naming an
iteration: tagging each screening call with its file path would give you one row
per file, none of which exists on the next run, and an agent budget cannot
accumulate against a name that lived for one fix. The per-invocation axis has
its own fields on the same event — customer_id and cost_center on
@capsera.agent, session_id on capsera.tag — so you can slice by which
repository or which team a fix was for without fragmenting the agent dimension
that budgets and cost attribution depend on.
Put a budget on the role that multiplies
The triager makes one call per run. The editor and the reviewer make one each per
attempt, and MAX_REVISIONS decides how many attempts there can be. All three of
those counts are in your file. The screener's is not: it comes from the triager's
output, which comes from how many files in your repository look relevant to a bug
report — and that number is nowhere in fixer.py. Point this agent
at a repository with two thousand files and raise MAX_CANDIDATES to the width
that finds anything, and the screener is the only row that moves. That is
multi-agent amplification in one
sentence, and it is why the cap belongs on the screener rather than on the run.
Create the budget in the dashboard with scope agent and the agent set to
screener — the same string as the decorator. That string is the whole join
between the two halves of this page: a budget can only act on a boundary the
events carry, and spend attributed to unknown cannot be governed by a
per-agent budget. Pick the amount from a week of
your own screening spend rather than a figure from a tutorial, and set the
action to block.
Enforcement is on by default (enable_budget_enforcement=True in init()) and
the check runs before the provider call. When the screener is over its
budget, BudgetExceededError is raised inside your process and the request
never leaves it, so no tokens are spent on it. This is
pre-call enforcement, and the cost of it is
one round trip to Capsera per call.
Then decide what a blocked screening call means for the fix. batch() raises on
the first failure and abandons the remaining inputs unless you tell it
otherwise, so this is a decision you make rather than one you inherit:
# fragment
import capsera
@capsera.agent("screener", team="repo-fixer", task_type="file-screening")
def screen(report: str, paths: list[str]) -> list[dict[str, str]]:
payloads = [
{"report": report, "path": p, "source": file_text(p)} for p in paths
]
verdicts = screen_chain.batch(
payloads,
config={"max_concurrency": SCREEN_CONCURRENCY},
return_exceptions=True,
)
findings: list[dict[str, str]] = []
for path, verdict in zip(paths, verdicts, strict=True):
if isinstance(verdict, capsera.BudgetExceededError):
# Do not draft a patch from a repository the agent only half read.
raise verdict
if isinstance(verdict, BaseException):
raise RuntimeError(f"screening {path} failed: {verdict}")
if verdict.strip().upper().startswith("SUSPECT"):
findings.append({"path": path, "verdict": verdict.strip()})
return findings
return_exceptions=True asks batch() to hand failures back as items in the
result list instead of raising on the first one, which is what lets you tell a
budget block from a flaky call and name the file that went unread. Without it,
one blocked screening call ends the round and the remaining candidates are never
even attempted.
Both cases stop the run here, and that is the judgement call worth arguing with. An unread file is a file that might hold the bug, and the next thing the agent would do is spend its most expensive call rewriting whichever file it did read. A patch produced that way is not incomplete, it is wrong, and its cost is a human review cycle rather than a token count. Degrading instead — drafting from the files that were read and telling the reviewer which were not — is defensible if the reviewer is given that fact, but it is the harder version to get right, and the safe default for a program that proposes code changes is to refuse.
The refusal then needs one place to land:
# fragment
import capsera
def run_once(report: str) -> dict[str, str] | None:
"""Returns None when a budget stopped the run."""
try:
return fix_issue(report)
except capsera.BudgetExceededError as exc:
print(f"screener budget reached, no patch produced: {exc}")
return None
BudgetExceededError is the only exception the SDK raises deliberately;
everything else fails open. Do not catch it and retry immediately — the budget
will still be exceeded. Narrowing MAX_CANDIDATES and rerunning, queueing the
report until the period rolls over, and failing are all defensible; for an agent
that files patches from a queue, failing usually is.
Three limits to know before you rely on this as a hard ceiling.
The blast radius is the role, not the run. A budget on screener stops
screening calls. The triager, the editor and the reviewer are different agents
with no budget on them, so a run that gets past screening still reaches the
expensive editor call and still pays for it. That is the right shape here —
the screener is the row that grows with the repository — but it is worth
deciding on purpose rather than discovering: if what you want capped is the
editor's model spend, that is a second budget on editor, not a side effect of
this one.
A parallel round can overshoot. The check reads budget state computed from
delivered events, and events are delivered in batches on an interval
(flush_interval_ms, 500 ms by default), so a burst of concurrent calls can
each pass a check taken before any of them was recorded. Here the burst is
SCREEN_CONCURRENCY — four as shipped, and whatever you raise it to when the
repository gets bigger. Set the budget below a figure you cannot exceed, or
lower the concurrency; it is the concurrency that decides the overshoot, not
MAX_CANDIDATES.
It fails open, on purpose. If the pre-call check does not complete within
budget_check_timeout (1 second by default), the call is allowed. A network
problem between your service and Capsera should not halt production traffic. Of
the three actions, block and margin downgrade act at runtime; throttle
records the decision but does not delay the call. The full behaviour is in
budgets and enforcement.
What this run actually cost
No dollar figures on this page: this agent has not been run against a real key, and a number invented here would not be yours anyway — it depends on your repository, how many files your triager names, and today's prices. What is worth predicting is the shape of the result.
Four agent rows — triager, screener, editor, reviewer — under one team,
repo-fixer, with a cost per run for the fix as a
whole, and the editor row split by task type into patch and patch-revision.
Read each row as call count and cost per call, not as a total. Three things can
move on this agent and each has a different fix. The screener's
call count moving means the triager named more files, or the repository grew.
The screener's cost per call moving means the files it named were bigger, which
is a property of your repository and not of your prompt. And a patch-revision
share that climbs means the reviewer is rejecting more patches — the most
expensive of the three, because every rejection buys another editor call on the
stronger model plus another review of it.
Which row is largest is not predictable in advance, and that is the point of
measuring it rather than reasoning about it. The editor makes one or two calls
on the more expensive model with a whole file in the prompt and a whole file in
the response; the screener makes a handful of small ones on the cheaper model.
Where the crossover falls depends on how wide you set MAX_CANDIDATES and on
the size of the file being rewritten, and it decides whether the next thing to
change is the editor's model or the width of the search.
Output tokens are worth watching on the editor specifically. It returns the complete file, so the same change costs more in a long file than a short one for reasons that have nothing to do with the change — the one place where asking for a diff instead of a file would pay, if a model could be trusted to produce hunk headers.
The screener and the editor both send text that repeats. Every screening call in
a round carries the same instruction with a different file, and a revision pass
sends the file the first pass already sent. The cache read share column is what
tells you whether any of that repetition was billed twice, and it is a number to
read rather than one to predict: a shared prefix has to be long and identical to
be worth anything, and reordering a prompt so the repeated part comes first is
the change that earns it. Turning on enable_prompt_analysis=True in init()
puts a figure on how often one system-prompt hash repeats, which is the evidence
for whether that restructuring is worth doing:
prompt caching that never engaged.
Each event also carries the file and line that made the call, which points at
fixer.py rather than into langchain_core, so a row you did not expect traces
back to code rather than to a model name.
Questions this page answers
How do I get per-agent cost for a LangChain coding agent? Call
capsera.init() once, which patches the provider client ChatOpenAI constructs
underneath, so every call the agent makes is already recorded. Then put
@capsera.agent("<role>") on each role function — the triager, the screener,
the editor, the reviewer. The decorator says who made the call; it does not
change whether the call is captured, and no function body, call site or model
construction changes.
Which agent in a code-fixing pipeline should carry the budget? The screening
role, because its call count is the one no line of your code sets: the triager
names the files and the repository decides how many there are to name. The editor
and the reviewer are one call each per attempt. A budget with scope agent on
the screener stops the fan-out before the editor is reached, which is also the
expensive call.
What should a coding agent do when a budget blocks part of its file
screening? Stop before the editor. An unread file is a file that might hold the
bug, and the next thing the agent would do is spend its most expensive call
rewriting whichever file it did read — a patch produced that way is not
incomplete, it is wrong. Pass return_exceptions=True to batch() so a blocked
call comes back as an item rather than aborting the round, tell a
BudgetExceededError apart from a flaky call, and refuse to draft.
Why do two runs of the same coding agent cost different amounts? Three things move between runs and none of them is a line you wrote. The triager decides how many files get screened, up to the cap you set. The screener's input tokens scale with the size of whichever files it named, which differ. And the reviewer decides whether the editor runs a second time. Watch call count and cost per call per role rather than the total, because those three causes have three different fixes.
Questions this page answers
- How do I get per-agent cost for a LangChain coding agent?
- Call capsera.init() once, which patches the provider client ChatOpenAI constructs underneath, so every call the agent makes is already recorded. Then put @capsera.agent("<role>") on each role function — the triager, the screener, the editor, the reviewer. The decorator says who made the call; it does not change whether the call is captured, and no function body, call site or model construction changes.
- Which agent in a code-fixing pipeline should carry the budget?
- The screening role, because its call count is the one no line of your code sets: the triager names the files and the repository decides how many there are to name. The editor and the reviewer are one call each per attempt. A budget with scope agent on the screener stops the fan-out before the editor is reached, which is also the expensive call.
- What should a coding agent do when a budget blocks part of its file screening?
- Stop before the editor. An unread file is a file that might hold the bug, and the next thing the agent would do is spend its most expensive call rewriting whichever file it did read — a patch produced that way is not incomplete, it is wrong. Pass return_exceptions=True to batch() so a blocked call comes back as an item rather than aborting the round, tell a BudgetExceededError apart from a flaky call, and refuse to draft.
- Why do two runs of the same coding agent cost different amounts?
- Three things move between runs and none of them is a line you wrote. The triager decides how many files get screened, up to the cap you set. The screener's input tokens scale with the size of whichever files it named, which differ. And the reviewer decides whether the editor runs a second time. Watch call count and cost per call per role rather than the total, because those three causes have three different fixes.
Give every agent an identity, a budget, and hard limits.
One line of code. Anthropic, OpenAI, and Google Gemini.
See pricing