Agentic Systems · Write Access

Teaching an agent to spend money safely

How to give a LangGraph multi-agent system the power to write to the outside world — create issues, send mail, issue refunds — without a slow API turning one action into fifty.

Updated September 2026 · Read ~18 min · Worked example: creating a GitHub issue


// the big idea

A read you repeat by accident wastes a few tokens. A write you repeat by accident bills a customer twice. The danger isn't a bug in your logic — a perfectly-reasoned, perfectly-formed write can still fire twice when the API is slow. Safety comes from idempotency controls around the call, not from the decision to make it.

Section 1

Notebook summary

The whole lesson, compressed for copying by hand. Every point below is unpacked in the Detailed Notes.

1 · Read vs write

  • read → idempotent · same call ×N = same state · failure = wasted tokens
  • write → NOT idempotent · same call ×N = N mutations · failure = irreversible $ / data loss
  • GET = idempotent · POST = not · damage ∝ times called
  • ⭐read misuse = inefficiency; write misuse = liability. Different problem class.

2 · Write amplification (the stuttering agent)

  • def: agent fires same write > intended, no logic bug
  • cause: API slow (6s) > tool-node timeout (5s) → agent reads "timeout" as FAIL → retries → both succeed
  • 1st write = ghost (no owner, no app record); agent only sees 2nd success
  • ⚠timeout that fires before the API replies is more dangerous than one that waits too long.
  • ⭐rule: tool timeout > API p99 + buffer (p99 3s → set 8–10s)
  • 3 factors, fix ALL: retry behavior · no dup-awareness · no state check before retry Agent → API bar (6s). Dashed "timeout@5s" line cuts it. Two boxes "Issue #1 (ghost)" + "Issue #2" out the right.

3 · Idempotency key

  • def: unique + deterministic id per logical write, sent alongside request
  • API stores key+result → same key again → returns stored result, no re-do
  • 2 properties: unique per logical op · deterministic (retry = same key)
  • build: SHA-256(thread_id : title : body) — thread alone fails if 1 thread makes many issues
  • ⚠anti-keys: timestamp / uuid4 (not deterministic) · internal-state-only (works till API edge case)

4 · Write taxonomy — control ∝ irreversibility

  • T1 reversible+audited (GitHub issue) → idempotency key + structured return + logging
  • T2 partly reversible (send email, CRM) → T1 + human-in-loop + rate-limit / thread
  • T3 irreversible (refund, DB delete, deploy) → T2 + hard approval graph node + crypto signing + dual audit log
  • 💡T3 approval = a real graph node that PAUSES, not a prompt the LLM can talk past. Rising 3-step staircase; x = irreversibility, y = engineering effort.

5 · Zero-trust tool auth

  • LLM gets semantic args ONLY (title, body, key). NEVER keys / tokens / secrets
  • creds injected by Python at module load via os.getenv, not in fn signature
  • ⚠a key passed through an LLM call is logged in ≥4 places → treat as compromised
  • ⭐validate creds at module load → fails at startup / in CI, not on a user's ticket at 3am

6 · Production write tool = 5 parts

  • @tool decorator · docstring · Pydantic args · body w/ runtime cred inject · structured return
  • docstring for the LLM (3 things): what it does (imperative) · when to call · exact success+fail format
  • Pydantic = type validation before the API call + per-field descriptions the LLM reads

7 · The void problem

  • write returns bare "success" → no URL, no id → unusable, undetectable dupes
  • return 5 fields: issue_number · issue_url · repository · created_at · idempotency_key · success
  • 💡analogy: a wire transfer confirmation says only "done" = negligent. Every field has a consumer.

8 · Errors & graceful degradation

  • split: transient (500/timeout → retry) vs permanent (401/403/404/422 → do NOT retry)
  • 401 fix token · 403 fix scope · 404 fix repo path · 422 fix payload (retry w/ correction)
  • degrade in 4: acknowledge · give the diagnosis anyway · say auto-escalation failed · give a next step
  • ⚠retrying a 401 forever = infinite loop. Never retry permanent errors.

9 · Binding & defense-in-depth

  • tool scope isolation: bind each agent ONLY its tools (synthesizer = none). Fewer tools = better choices
  • 2 defenses, complementary: internal state guard (blocks the retry decision) + server idempotency key (blocks concurrent double-fire)
  • ⭐write findings carry confidence = 1 = "confirmed fact", not a probability. Outcome lives in the type, certainty in the score.
Section 2

Detailed study notes

A support system runs flawlessly for three months. It reads customer records, searches a knowledge base, classifies tickets — ten thousand a day, every action read-only, never a wrong move. Then one sprint adds a single capability: the agent can now issue partial refunds through the payment API when it confirms a billing error. It ships Tuesday. By Thursday the billing team notices forty-seven customers were refunded twice, roughly twenty-three thousand dollars gone, over forty-eight hours. No attacker. No bug in the refund logic. The agent did exactly what it was designed to do. What it lacked was the discipline that write access quietly demands — and that discipline is the whole of this lesson.

2.1Reading is free. Writing has consequences.

A read can be repeated harmlessly; a write cannot. This is the asymmetry every write-capable agent is built on. Everything you have wired up so far — pulling a record from Mongo, running a text-to-SQL query, classifying a ticket — is a read. Reads are idempotent: run one five times and you get the same answer five times and the system on the other end is untouched. That is a plain-English way of saying an operation you can safely repeat.

Writes break that promise. The formal test: an operation is idempotent only if executing it N times leaves exactly the same system state as executing it once. An HTTP GET passes — five identical GETs, same data, no change. An HTTP POST fails — five identical POSTs typically create five records, charge five times, send five notifications.

Analogy · the elevator button

Calling a lift is idempotent — jab the button five times and the same lift still comes; the state doesn't change. Submitting a wire transfer is not. Query a balance twice and you've just read the same number twice. Submit a transfer twice and the account is debited twice, and now you're phoning the receiving bank, filing a recall, and updating ledgers on both sides.

// read operation

Consequence-free

  • Repeat N times → identical result
  • Server state unchanged
  • Worst case: wasted tokens, latency
  • Failure mode = inefficiency
// write operation

Irreversible at scale

  • Repeat N times → N state changes
  • Damage scales with call count
  • Worst case: financial / data loss
  • Failure mode = liability
⭐ trick · reframe the whole problem

The read-vs-write line is not a performance concern, it's a correctness and liability concern. No unit test catches these failures, because each individual write is correct. What's wrong is the aggregate — many correct calls combining into an incorrect outcome. At 10,000 tickets/day, a mere 0.1% duplicate-fire rate is 10 bad writes a day, ~300 erroneous mutations a month, quietly accumulating.

2.2Write amplification and the stuttering agent

Write amplification is when an agent fires the same write more times than it meant to — with no logic bug and no malformed call. The decision was right and the request was well-formed; the execution layer simply lacked the control to stop a duplicate. The behavioral name for the agent doing this is the stuttering agent: it repeats itself not from malice but because it never heard a clear confirmation that the first attempt was received.

Analogy · the stuck QR payment

You scan to pay at a shop, the screen hangs, and — half from embarrassment — you toggle airplane mode and pay again. Years ago that meant two deductions. Today it usually doesn't, because banks added idempotency checks: the second attempt carries the same identity as the first, so the backend recognizes it and refuses to double-charge. Your agent needs the exact same trick.

Here is the precise causal chain, because you'll want to recognize it on sight. The agent decides an engineering escalation is needed and calls create_github_issue. GitHub is under peak load, so the API takes 6 seconds. The LangGraph tool node is configured with a 5-second timeout. At 5s it gives up and returns a timeout message to the agent. The agent's working memory is just its message history, and that history now reads "attempt, then timeout" — indistinguishable from a genuine failure. It reasons, "my call failed, I should retry," and fires the identical call again. Both calls eventually succeed. Two identical issues exist. The agent only ever saw the second success, reports one issue, and the first sits in the repo as a ghost: no owner, no reference from the app, invisible to the normal resolution flow.

Sequence diagram of the stuttering agent creating two GitHub issues React Agent GitHub API create_issue(A) t=0s processing · 6s tool-node timeout @5s → "FAILED" Issue #1 created — nobody listening create_issue(A) [retry] Issue #2 → reported to user ✓ 👻 ghost issue
The timeout fires before the API answers; the agent retries a call that had already succeeded.

Three factors must all be controlled — fixing one or two is not enough for production safety:

retry behavior

On a timeout the agent retries. Fix: a deterministic idempotency key so the API ignores the duplicate.

no dup-awareness

The tool never checks if this op already completed. Fix: pass thread_id + content hash as the key.

no state check

The loop never inspects its own state before retrying. Fix: an internal state guard on prior findings.

⭐ trick · the one config that prevents most of this

Set the tool timeout higher than the API's documented p99 response time, plus a buffer. If GitHub documents a p99 of 3s, set the tool timeout to 8–10s — never lower. A tool that times out before the API replies and then retries is far more dangerous than one that simply waits a little too long.

2.3Idempotency keys — the shield

An idempotency key is a unique, deterministic name for one logical write, sent alongside the request so the API can recognize a repeat. The external API stores the key together with the result of processing it. If a later request arrives carrying the same key, the API returns the stored result instead of doing the work again. That single mechanism is what turns "fired twice" into "fired once, acknowledged twice."

To be correct the key must satisfy two properties at the same time:

1 · uniqueness per logical op

Two different tickets that each deserve an issue must produce different keys. A constant string would make every issue after the first look like a duplicate — so none would ever be created.

2 · determinism

The same logical op must always produce the same key. A retry has to compute the identical key as the first attempt, or the API can't tell they're the same request.

LangGraph hands you a natural base for this: the thread_id, which is unique per conversation and stable across retries within it. That satisfies both properties — until a single thread legitimately needs several issues (one complex ticket touching multiple components). So the key must also encode the content of the specific operation, not just the thread. The standard recipe hashes all three together:

thread_id+ title+ body→ SHA-256→ idempotency_key (hex)
idempotency.py
import hashlib

def generate_idempotency_key(thread_id: str, title: str, body: str) -> str:
    """Deterministic key for one logical write.
    Same inputs -> same key (retries collapse); different op -> different key."""
    payload = f"{thread_id}:{title}:{body}".encode("utf-8")
    return hashlib.sha256(payload).hexdigest()
⚠ pitfall · keys that quietly do nothing

Three anti-patterns defeat idempotency, and you must catch them in review:

  • Timestamp key — a retry happens at a new instant, so it gets a new key. Never deterministic.
  • uuid4() key — random by design, unrelated to content; original + two retries look like three distinct ops.
  • Internal-state-only — the most dangerous, because it appears to work. It leans entirely on your own guard and collapses the moment the API is unreachable or gets swapped for one with weaker idempotency support. Safety controls should never rest on a single layer.

2.4Not every write is equally dangerous

Classify each write by how reversible it is, then spend controls in proportion. A system that slaps maximum controls on everything wastes effort on cheap operations and, worse, tends to under-protect the expensive ones. The fix is a risk-proportionate taxonomy with three tiers and escalating requirements.

Three-step staircase: engineering investment rising with irreversibility irreversibility → engineering effort → Tier 1 reversible · GitHub issue Tier 2 partial · send email Tier 3 irreversible · refund, delete key + return + log + human confirm + rate-limit + approval node + signing
Progressive control escalation: the cost of the controls tracks the cost of getting it wrong.
Tier Reversibility Example Controls (cumulative)
T1 · low Fully reversible, fully audited Create GitHub issue, CRM ticket, calendar event, record comment Idempotency key · structured return metadata · write logging for dup detection
T2 · medium Partly reversible; may reach an external consumer Send an email, update a CRM record, publish to a queue All T1 · human-in-the-loop confirm before execute · rate-limit per thread_id
T3 · high Irreversible; direct financial / data loss Issue refund, delete DB record, trigger a deploy, charge a card All T2 · mandatory approval graph node · cryptographic signing · dual audit logging
💡 tip · what "approval" actually means at T3

A T3 approval is a hard node in the graph topology that pauses execution and surfaces the proposed action to a human, waiting for an explicit sign-off before the tool can fire. It is deliberately not a confirmation prompt in the conversation — the LLM can rationalize its way past a prompt, but it cannot execute past a paused graph. (The real incident behind this: an AI coding assistant wiped a startup's production database and its backups in seconds because nothing paused it.)

2.5Zero-trust: the model never touches the keys

Give the LLM exactly what it needs to decide the call, and nothing else — never credentials. The semantic arguments (issue title, body, repo label, the idempotency key) are the model's domain. The authentication that says who is allowed to perform the operation is the Python runtime's domain. These two concerns must never cross.

Zero-trust boundary between the LLM and the Python runtime LLM domain title · body label · idempotency_key (logged in ≥4 places) never crosses Python runtime GITHUB_ACCESS_TOKEN injected via os.getenv at module load, not in signature
Semantic args flow through the model; secrets are injected on the runtime side only.

Why non-negotiable: an LLM's inputs and outputs are logged everywhere. A single tool call lands in the LangChain callback trace, the LangSmith run tree, the persisted checkpoint state, and the provider's own request logs. A token passing through those tool arguments has been written to at least four storage systems, any of which could leak it. A key that passes through an LLM call is a compromised key — not immediately, but inevitably.

module load — credential injection
import os

# Injected by the runtime at MODULE LOAD, never passed through the LLM.
GITHUB_TOKEN = os.getenv("GITHUB_ACCESS_TOKEN")
if not GITHUB_TOKEN:
    # Fail at startup / in CI, not on a user's ticket at 3am.
    raise RuntimeError("GITHUB_ACCESS_TOKEN not set; refusing to start.")
⭐ trick · validate at load, not at call

Put the credential check at module load, not inside the tool function. Inside the function, a missing token fails only when a user submits a ticket in production — likely when nobody's watching. At module load it fails at application startup: the deploy pipeline catches it, the error is unambiguous, and no user ever reaches the misconfigured system.

2.6Anatomy of a production write tool

A production-grade write tool has five parts, each doing one job. This is the same tool-calling pattern you already know, tightened for a write. The parts: the @tool decorator, the docstring, the Pydantic argument schema, the function body with runtime credential injection, and the structured return value.

The docstring is not developer documentation — it is the specification the LLM reads to decide whether and how to call the tool. For a write tool it must carry three things: what the tool does in imperative form, when to call it, and the exact return format on success and failure. The Pydantic schema earns its place two ways: it validates argument types before the API call fires (a malformed key is rejected up front), and its per-field descriptions are what the LLM reads while constructing the values.

tools/github.py — schema + decorator
from langchain_core.tools import tool
from pydantic import BaseModel, Field

class CreateIssueArgs(BaseModel):
    title: str = Field(description="Short imperative summary of the bug.")
    body: str = Field(description="Structured technical description of the issue.")
    idempotency_key: str = Field(
        description="32+ char hex string uniquely identifying this request."
    )

@tool(args_schema=CreateIssueArgs)
def create_github_issue(title: str, body: str, idempotency_key: str) -> str:
    """Create a GitHub issue in the engineering bug-tracker repository.

    Call ONLY when knowledge-base search returned no applicable resolution
    for a confirmed technical issue needing engineering intervention.
    Do NOT call for billing questions, account questions, or fraud reports.

    On success return JSON with: issue_number, issue_url, repository,
    created_at, idempotency_key, success=true.
    On failure return JSON with: success=false, error_type, message.
    """
    ...  # body follows in 2.7
💡 tip · bind only what an agent needs

Tool scope isolation: each agent is bound (via llm.bind_tools(...)) to only the tools it's authorized to use. The triage agent classifies and gets no write tools; the fraud agent checks signals and can't touch the bug tracker; the synthesizer merges findings and has no tools at all. This isn't only security — an agent with fewer, relevant tools makes more accurate tool choices.

2.7The void problem — writes must return metadata

A write that returns only "success" throws its result into a void. The operation happened, the system mutated, but the agent, the synthesizer, and any downstream consumer learn nothing about what was created, where to find it, or how to reference it. This bites hardest for operations that create addressable resources — things with an id and a URL a user could open. GitHub assigned an issue number and a full URL; if the tool returns "issue created successfully," that information is discarded before it can be used. The user can't navigate to it, the synthesizer can't cite it, the audit log can't record which issue, and a later attempt can't detect it already exists.

Analogy · the wire-transfer confirmation

A bank wire confirmation never says just "done." It returns a transaction reference, the exact amount, a masked destination, a value date, and a SWIFT code — each field serving a specific downstream consumer (tracking, reconciliation, compliance). A confirmation that said only "transfer completed" would be considered negligent. Hold your write tool to the same standard.

Field Type Why it exists
issue_number int Unique within the repo; lookup without the full URL
issue_url string The artifact the user can open, bookmark, share
repository owner/repo Confirms which repo received it; enables routing + audit
created_at ISO 8601 Establishes the audit timeline; correlates with other events
idempotency_key hex Confirms which logical op this response answers — not a stale cached one
success bool Lets the caller branch cleanly on outcome
tools/github.py — body with structured return + error taxonomy
from github import Github, GithubException
import os, json

def create_github_issue(title, body, idempotency_key):
    try:
        repo = Github(GITHUB_TOKEN).get_repo(os.getenv("GITHUB_REPO"))
        issue = repo.create_issue(title=title, body=body)
        return json.dumps({                       # structured metadata, no void
            "success": True,
            "issue_number": issue.number,
            "issue_url": issue.html_url,
            "repository": repo.full_name,
            "created_at": issue.created_at.isoformat(),
            "idempotency_key": idempotency_key,
        })
    except GithubException as e:
        transient = e.status in (500, 502, 503)   # retryable vs permanent
        return json.dumps({
            "success": False,
            "error_type": "transient" if transient else "permanent",
            "status": e.status,
            "message": str(e.data),
        })

2.8Error handling and graceful degradation

A write tool must sort failures into "retry" and "don't," and always leave the user with something useful. External APIs fail in kinds: some are transient (a brief server overload that clears in seconds), some are permanent (an auth failure that needs a config change), and some are in between (a validation error the agent can fix and re-send). Treating them alike is how you get an infinite loop — retrying a 401 forever will only produce another 401.

Status Meaning Retry? Fix
401 Token invalid / expired No — permanent Fix the token; log for ops
403 Token valid, lacks scope No — permanent Fix the token's permission
404 Repo missing / no visibility No — permanent Fix repo owner/path in env
422 Payload violates validation Yes, once corrected Agent fixes the malformed field, re-sends
500 / timeout Transient server / network Yes — with the same key Back off and retry idempotently
⚠ pitfall · 401 vs 403 look alike, aren't

Both are permanent, both mean "do not retry" — but the distinction matters for whoever debugs it. 401 = fix the token itself; 403 = the token is fine, fix its permissions. Return the specific error type so ops isn't guessing.

Graceful degradation is the guarantee that when the write fails, the user still gets a useful response — not that failures are hidden. A support system that crashes or dumps a raw error when GitHub is briefly down has failed its user twice: once because the issue wasn't created, and again because they got no idea what happened or what to do. The correct degraded response for a failed issue creation has four parts:

1 · acknowledge

Say plainly the technical issue can't be resolved right now.

2 · give the diagnosis

Include the full analysis the tech agent already produced — that work shouldn't be lost.

3 · name the failure

Say automatic escalation was attempted and is temporarily unavailable.

4 · offer a path

Give a concrete next step: contact support, reference the thread_id, request escalation.

2.9Two guards, and the confidence marker

The internal state guard and the server-side idempotency key are complementary, not redundant — they defend different failures. The guard checks the agent's own prior findings before it even constructs a retry, so it stops the stuttering failure (a deliberate retry after reading a timeout) and saves the network round-trip entirely. The server key stops concurrent double-fires — two calls reaching GitHub at once, e.g. under parallel execution — that a local guard would miss. First line of defense and last line of defense.

Defense in depth: internal guard then server key protecting the API Agent wants to retry 1 · state guard blocks the retry decision 2 · server key blocks concurrent double-fire API
A write reaches the API only after passing both an in-agent check and a server-side check.
⭐ trick · confidence is a fact flag, not a probability

When a write records its outcome as a finding, set confidence = 1 on both success and failure. Here 1 means "this is a confirmed fact, not an LLM inference" — the write really happened, or really failed. A finding of type github_issue_created with confidence 0.3 is incoherent: the issue either exists or it doesn't. The type carries the outcome; the score carries certainty — and for a confirmed write that certainty is total.

2.10Interview & practice Q&A

Q1Why is a duplicate write far worse than a duplicate read, in one sentence?

A read is idempotent, so repeating it only wastes tokens and time while the system state stays put; a write mutates state, so repeating it multiplies the mutation — five refunds, five records — and the damage scales with the number of calls and is often irreversible.

Q2Walk me through how a slow API turns one write into two.

The agent fires a write; the API is under load and takes 6s; the tool node's 5s timeout expires first and returns a "timeout" message. The agent's message history now looks like "attempt then failure," so it retries the identical call. Both eventually succeed. The agent only sees the second success and reports one action — the first becomes a ghost with no owner and no app record.

Q3What two properties must an idempotency key satisfy, and why both?

Uniqueness per logical operation, so distinct writes get distinct keys and aren't wrongly collapsed; and determinism, so a retry recomputes the identical key and the API can recognize it as the same request. Miss uniqueness and only one issue ever gets created; miss determinism and every retry looks brand new.

Q4Why can't you use a timestamp or uuid4() as an idempotency key?

Both break determinism. A timestamp differs every instant, and uuid4() is random by design — so the original call and each retry produce different keys, and the external API treats them as separate operations. That's idempotency failure: the exact duplication you were trying to prevent.

Q5Why must the tool timeout be higher than the API's p99, and not lower "to fail fast"?

Because a timeout that fires before the API replies makes a successful-but-slow call look like a failure, which triggers a retry and a duplicate write. Waiting slightly too long only costs latency; giving up too early costs correctness. Set the timeout to the documented p99 plus a buffer (p99 3s → 8–10s).

Q6Why should credentials be validated at module load rather than inside the tool?

Inside the function, a missing token surfaces only when a live user triggers the tool — often in production with nobody watching. At module load the service fails to initialize, so the deploy pipeline catches it, the error is unambiguous, and no user reaches a misconfigured system. It also keeps the secret out of the LLM's arguments entirely.

Q7The internal state guard already prevents retries — why also add a server-side key?

They cover different failures. The guard stops the agent's deliberate retry after a timeout, but it can't help if two calls hit the API concurrently (say under parallel execution) — neither has seen the other's result yet. The server key catches that. Relying on the guard alone also means your safety collapses the moment the API is unreachable or swapped out.

Q8Which errors do you retry, and which do you never retry?

Retry transient failures — 500/502/503 and network timeouts — using the same idempotency key. Never retry permanent ones: 401 (bad token), 403 (missing scope), and 404 (wrong repo) will just repeat forever. A 422 validation error is retryable only after the agent corrects the offending field.

Q9Why is a write finding recorded with confidence = 1 even when it failed?

Because confidence here marks "confirmed fact," not probability. A completed or failed write is something that definitely happened, not an inference to hedge. The finding's type says whether it succeeded; the confidence of 1 says the system is certain of that outcome. A "created with 0.3 confidence" finding would be incoherent — the issue exists or it doesn't.

2.11Key takeaways

read = idempotent write = liability stuttering agent timeout > p99 deterministic key T1 · T2 · T3 approval node ≠ prompt creds @ module load no void guard + server key confidence = fact

Section 3

Microexercises

Reading proves you recognize the pattern. These prove you can build it. Attempt each before opening the walkthrough.

3.1Diagnose it — the write that fires twice

// your task

The tool below is deployed against an API whose p99 latency is 5s. Predict exactly what happens on a slow call, name the two independent defects, and state the fix for each.

Requirements:

  • Say what the user and repo end up with after one slow request.
  • Identify why the idempotency key does not protect anything here.
  • Identify the timeout defect and give the corrected value.
broken_tool.py
import uuid
from langchain_core.tools import tool

TOOL_TIMEOUT = 3  # seconds

@tool
def create_github_issue(title: str, body: str) -> str:
    key = str(uuid.uuid4())          # "idempotency" key
    return github_api.create_issue(title, body, idempotency_key=key,
                                   timeout=TOOL_TIMEOUT)
▸Show the full walkthrough

Root cause — two defects that reinforce each other: a non-deterministic key and a timeout below the API's latency.

Step 1 — predict the failure. The API's p99 is 5s but the timeout is 3s, so a normal-slow call is abandoned at 3s. The agent reads that as failure and retries. Meanwhile the first call is still completing. You end up with two issues in the repo; the user is told about one, and the first is a ghost.

Step 2 — why the key is useless. uuid.uuid4() generates a fresh random value on every invocation, including the retry. The original and the retry send different keys, so the API sees two distinct operations. The key satisfies uniqueness but violates determinism, so it prevents nothing.

Step 3 — the timeout defect. A timeout under the p99 guarantees premature give-ups on healthy calls. It must sit above the p99 plus a buffer.

Step 4 — the fix.

fixed_tool.py
import hashlib
from langchain_core.tools import tool

TOOL_TIMEOUT = 10  # > p99 (5s) + buffer

@tool
def create_github_issue(title: str, body: str, thread_id: str) -> str:
    # deterministic: a retry recomputes the identical key
    key = hashlib.sha256(f"{thread_id}:{title}:{body}".encode()).hexdigest()
    return github_api.create_issue(title, body, idempotency_key=key,
                                   timeout=TOOL_TIMEOUT)
why A deterministic key lets the API collapse the retry into the original, and a timeout above p99 stops the premature retry from being triggered at all — the two fixes attack the two independent factors behind write amplification.

3.2Build it — the key and the guard

// your task

Write two small pieces: the deterministic key generator, and the internal state guard that stops the tool from ever being called when this logical write has already succeeded in the agent's state.

Requirements:

  • generate_idempotency_key(thread_id, title, body) returns a hex SHA-256 digest; identical inputs return identical output.
  • guard(state, key) returns the existing issue_url if a prior successful finding in state["findings"] carries that key, else None.
  • Expected: given a findings list already holding {"idempotency_key": k, "success": True, "issue_url": "...", }, calling guard with the same k returns that URL and no API call is made.
▸Show the full walkthrough

Step 1 — the key. Join the three inputs in a fixed order, encode, hash with SHA-256, return hex. Order matters: it must be identical on every call so retries collide.

Step 2 — the guard. Before building the tool call, scan prior findings for one that both succeeded and matches this key. If found, reuse its URL and skip the API entirely — this is the first line of defense that saves the round-trip.

solution.py
import hashlib

def generate_idempotency_key(thread_id: str, title: str, body: str) -> str:
    payload = f"{thread_id}:{title}:{body}".encode("utf-8")
    return hashlib.sha256(payload).hexdigest()

def guard(state: dict, key: str) -> str | None:
    for f in state.get("findings", []):
        if f.get("success") and f.get("idempotency_key") == key:
            return f["issue_url"]        # reuse; do NOT call the API
    return None

# usage inside the agent, before firing the tool
key = generate_idempotency_key(thread_id, title, body)
existing = guard(state, key)
if existing:
    return f"Issue already exists: {existing}"
# else: fall through to create_github_issue(title, body, key)
why The key makes retries recognizable and the guard stops the retry decision before it becomes a network call — together they are the in-agent half of defense in depth, complementing the server-side key that catches concurrent fires.
predict the ghost uuid ≠ deterministic timeout > p99 + buffer SHA-256(thread:title:body) guard before you fire

Built from the session transcript and the accompanying materials. Code samples reconstruct the standard LangChain / PyGithub patterns the lesson described. Tags: [+] added for precision · [!] source conflict flagged inline (none arose).