Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/content/blog/ai-agents-fall-2026-what-changed.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ title: "AI agents in fall 2026: what changed, and how to build for it with 10xGr
seoTitle: "AI agent trends, fall 2026, with 10xGraph code"
description: "Cheaper models, a stateless MCP spec, MCP CVEs, approvals everywhere and EU transparency rules: what changed in AI in 2026 and the 10xGraph code to handle each one."
date: 2026-10-18
updated: 2026-10-27
author: Shudipto Trafder
kind: engineering
tags: [industry, models, mcp, security, reliability]
Expand Down Expand Up @@ -457,6 +458,8 @@ The model sees only `order_id` and `amount`. The scope list comes from your auth

</Callout>

Since this section was written, core has tightened the defaults that sit under these checks. A handoff only happens through a tool made with `create_handoff_tool` and registered on that tool node; a model that writes a `transfer_to_*` call for any other name gets an ordinary tool call, and a jump to an unknown node raises `GRAPH_007`, so a model cannot talk its way past edges and approval steps. Tools hidden with `tools_tags` are now refused at run time, not only left out of the model's tool list. An MCP tool with the same name as a local or remote tool is an error instead of a silent override. The prebuilt `fetch_url` tool checks every redirect hop and pins the resolved address, so a public page cannot bounce the agent to an internal host. These cut the damage a hijacked model can do; they do not stop prompt injection, and the checklist below still applies.

### Operational checklist for MCP

- **Patch and inventory.** Upgrade LiteLLM past 1.84.0 if you run its proxy, and list every MCP server your agents can reach.
Expand Down Expand Up @@ -683,14 +686,14 @@ Neither deal changes anything on day one. Both are a reminder that a gateway, a

```bash title="Terminal"
10xgraph init --template production --auth jwt --rate-limit redis --name support-agent --yes
10xgraph api --no-reload # local server on http://127.0.0.1:8000
10xgraph dev --no-open # local server on http://127.0.0.1:8000
10xgraph build --docker-compose --k8s
```

What each command does:

- **`init`** writes the graph package, the prompt-injection validator, an `evals/` folder, tests, `.env.example` and `10xgraph.json`.
- **`api`** serves REST, streaming and WebSocket endpoints for the graph. `--reload` is on by default for development, so pass `--no-reload` anywhere else.
- **`dev`** serves REST, streaming and WebSocket endpoints for the graph on a local Uvicorn server. `--reload` is on by default and `--no-open` skips opening the hosted playground. It is a development server: production runs the Gunicorn image that `build` writes.
- **`build`** writes a `Dockerfile`, `docker-compose.yml` and `k8s.yaml` with a Deployment and a Service.

Wire `PgCheckpointer` in your graph module and pass it to `compile()`. The server uses the checkpointer your compiled graph carries; the `checkpointer` key in `10xgraph.json` is not read. See [Serving agents](/docs/concepts/serving-agents).
Expand Down Expand Up @@ -809,7 +812,7 @@ checkpointer = PgCheckpointer(
app = graph.compile(checkpointer=checkpointer, callback_manager=callbacks)
```

Point `"agent"` in `10xgraph.json` at `graph.agent:app`, run `10xgraph api`, and the API server serves this graph with JWT auth, thread ownership and rate limits. The TypeScript client (`npm install 10xgraph-client`) connects a web frontend to it.
Point `"agent"` in `10xgraph.json` at `graph.agent:app`, run `10xgraph dev`, and the API server serves this graph with JWT auth, thread ownership and rate limits. The TypeScript client (`npm install 10xgraph-client`) connects a web frontend to it.

## What 10xGraph does not do yet

Expand Down
91 changes: 91 additions & 0 deletions src/content/blog/approval-step-prompt-injection.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
---
title: "A prompt-injected model cannot skip your approval step"
seoTitle: "Prompt injection cannot skip your approval step"
description: How 10xGraph keeps handoffs, tool visibility, tool results and interrupt resumes under the graph's control, so a steered model cannot walk around a human approval.
date: 2026-10-27
updated: 2026-10-27
author: Shudipto Trafder
kind: engineering
tags: [security, interrupts, human-in-the-loop, multi-agent]
draft: true
faq:
- q: Does this stop prompt injection?
a: "No. Nothing stops a model from being steered by text it reads. These controls limit what a steered model can do to the parts of the graph you did not give it: the route it takes, the tools it can call and the approval a human gives."
- q: Can a client approve an interrupt by sending a tool result?
a: No. The API accepts a tool result only as the answer to a remote tool call that is waiting for one. A result for a server tool, for a call the model never made, or a second result for the same call is refused.
- q: What does interrupt_id add to a resume?
a: It ties the answer to the exact prompt the human saw. If the thread is paused at a different interrupt, or not paused at all, the API answers 409 and nothing is recorded.
---

A human-in-the-loop design usually has the same shape: the model proposes an action, the graph stops at an approval step, a person says yes or no, and only then does the tool run. The guarantee you want is that the model has no way to reach the tool without passing the stop.

That guarantee holds only if every path around the stop is closed, including the paths a model can influence by text alone. A model that has read a hostile web page or a poisoned document can be steered into producing unusual output: a tool name you did not expect, a routing hint, a message that looks like a human answer. This post lists the places where 10xGraph decides on the graph's side instead of trusting that output, and what you still have to do yourself. It describes behaviour and design, not ways to attack a particular app.

The reference pages are [Interrupts](/docs/concepts/interrupts) and the [security model](/docs/production/security-model).

## Handoffs only go where a handoff tool says

In a multi-agent graph, one agent can hand the conversation to another through a tool made with `create_handoff_tool`. Earlier, any tool call named `transfer_to_<X>` moved the run to node `<X>`, whether or not you had registered such a tool. A steered model could name a node that your edges never lead to, and that includes a node behind an approval step.

Now a handoff is honoured only when the ToolNode holds a tool of that name created by `create_handoff_tool`. Any other `transfer_to_*` call is an ordinary tool call, and if no such tool exists it gets the usual "Tool not found" result.

The same rule covers `Command(goto=...)` returned from your own code. A target that is neither a node in the graph nor `END` raises `GraphError` with code `GRAPH_007`, naming the node it came from and the unknown target. Before, it surfaced as a raw `KeyError`.

Starting a run at a chosen node with `current_node` is a separate, intended feature (a frontend can send the agent the user picked). If a node needs an entry guard, enforce it inside the graph. The change above is about what the model can do, not what your caller can do.

## Tools hidden by `tools_tags` cannot be called

An Agent can advertise only the tools that match `tools_tags`. That used to be a presentation choice: the model was shown the tagged tools, but the ToolNode would run any registered name the model happened to guess.

Now the Agent records the names it advertised on the assistant message (`metadata["allowed_tools"]`), and the dispatch step refuses a call to anything outside that list with the same "Tool not found" error. If you use tags to keep a destructive tool away from a low-trust agent, the tag now holds at run time and not just in the prompt.

## A tool name cannot be claimed twice

When a ToolNode also connects to an MCP server, a remote tool could previously take over the name of a local tool: calls meant for your function went to the server. Now a collision between an MCP tool and a local or remote tool on the same ToolNode raises `ValueError` ("Rename one of them"), and the check runs outside the code that swallows connection errors, so it cannot be hidden by a failed server ping.

## Forged tool results are refused

Remote tools run on the client, and the client sends the result back to resume the run. That is the one case where the API accepts a message with the `tool` role, and it is narrow.

- Client messages may only have the `user` role. The `assistant` and `system` roles are refused, and so is a `tool` message that carries anything other than tool results.
- A tool result is accepted only for a remote tool call that is waiting for one on that thread. A result for a call the model never made, a second result for the same call, or a result for a server tool is refused.
- A client tool can never carry the name of a server tool. Otherwise a client could declare a tool with the name of the one paused for approval and post a "result" for it.

The effect is that the approval path cannot be satisfied by pretending to be the model or the tool.

## A resume answers one specific question

A resume carries a value, and the value can be bound to the prompt it answers:

- The client can send `interrupt_id` with the resume on invoke, stream and WebSocket. If the thread is not paused, or is paused at another interrupt (a stale tab, a retried request after the first answer was consumed), the API answers 409 and records nothing. This is how a late "yes" fails to approve a different request.
- The core checks the id again and, when the interrupt declared a `response_schema` (a JSON Schema), validates the value against it. A mismatch raises `InterruptMismatchError` (`INTERRUPT_001`), and a value that does not satisfy the schema raises `ResumeValidationError` (`INTERRUPT_002`). Nothing is recorded in either case.

The core checks are in the repository and ship with the next core release (0.10.4 or later). The API check works with the core you have today, and it passes the id along for the core to enforce once you upgrade.

```python
from tenxgraph.utils import interrupt


async def refund(amount: float) -> str:
decision = interrupt(
{"amount": amount},
message=f"Refund ${amount}?",
reason="tool_approval",
response_schema={
"type": "object",
"properties": {"approved": {"type": "boolean"}},
"required": ["approved"],
},
)
if not decision or not decision.get("approved"):
return "Refund declined"
return issue_refund(amount)
```

Without `interrupt_id`, a resume answers whatever the thread is paused at, as before. Send the id from any client where a person can have two tabs open.

## What you still own

- None of this makes a model trustworthy. It removes shortcuts around your graph.
- Authentication, per-user thread ownership and tool authorization are separate layers, covered in the [security model](/docs/production/security-model).
- A tool that must be safe against a steered model should still validate its own arguments and use idempotency keys. See [Interrupts](/docs/concepts/interrupts) for how to place the stop before the tool, not after it.
78 changes: 78 additions & 0 deletions src/content/blog/bounded-runs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
title: "Bounded by design: timeouts, tool caps, request limits and one run per thread"
seoTitle: "Bounded agent runs: timeouts, caps, limits"
description: "The limits a 10xGraph run has by default: parallel tool cap, run, node and tool timeouts, per-user run cap, one run per thread, request size limits and paging caps."
date: 2026-10-27
updated: 2026-10-27
author: Shudipto Trafder
kind: engineering
tags: [reliability, scaling, limits, production]
draft: true
faq:
- q: What is the default run timeout?
a: With MODE=production and no runs.timeout in 10xgraph.json, a run is cancelled after 300 seconds. In development there is no limit. An explicit number overrides the default, and an explicit null removes the limit in either mode.
- q: What happens when two requests use the same thread at once?
a: The second waits up to 30 seconds for the first to finish, then gets 409. With on_busy set to cancel, it asks the active run to stop first.
- q: Do these limits apply to streaming runs?
a: runs.timeout, the per-user run cap, the thread lock and the request size limits do. Node and tool timeouts and the tool ledger are part of the invoke path and do not apply to streaming runs.
---

Agents are loops that call a model and a set of tools, and nobody wrote down in advance how long the loop will take or how many tools one turn will start. A hung tool, a model that emits hundreds of calls in one message, two tabs sending to the same thread and a client that posts a huge body are all ordinary events once real users arrive. If nothing bounds them, the first one that happens takes the worker down.

This post lists the bounds that 10xGraph and `10xgraph-api` apply by default, with the numbers from the code. They are a backstop against runaway work, not a latency budget. The operating guidance is on the [scaling page](/docs/production/scaling).

## Parallel tool calls: at most 10 at a time

One model turn can ask for many tool calls, and the ToolNode runs them in parallel. Each parallel branch holds a deep copy of the state, so memory grows with the call count. In a test of one turn with 300 tool calls, the unbounded version used 2.4 GB.

At most `max_parallel_tool_calls` run at once, and the default is 10. Set it in the run config, process-wide with the `TENXGRAPH_MAX_PARALLEL_TOOL_CALLS` environment variable, or, when you serve with the API, as `runs.max_parallel_tool_calls` in `10xgraph.json`. The API writes it into every run's config, so clients cannot raise it.

## Timeouts at three levels

| Bound | Default | Where it is set |
|---|---|---|
| Whole run | 300 s with `MODE=production`, no limit in development | `runs.timeout` in `10xgraph.json` |
| One node | 900 s | `node_timeout` in the run config |
| One tool call | 300 s | `tool_timeout` in the run config |

A run that passes `runs.timeout` is cancelled: invoke answers 504, and a stream, WebSocket run or AG-UI run ends with an error. Realtime sessions are not limited. An explicit number overrides the default and an explicit `null` removes the limit, in either mode.

The node and tool defaults are generous on purpose. The node limit sits above the 600 s default timeout of the model SDKs, so a slow model call fails with its own error instead of being masked by the node deadline. Both can be disabled with `None` or `0`.

## One run per thread

Two runs writing the same thread would race on its state. The API takes a per-thread lock for invoke, stream and WebSocket runs. A request that finds the thread busy waits up to 30 seconds and then gets 409.

Send `on_busy` with the request to choose what a waiting request does:

- `"wait"` (the default) waits for the active run to finish.
- `"cancel"` first asks the active run to stop, and repeats the request every second while it waits, in case the run had not saved its state yet.

With Redis configured the lock is a lease that a background task renews while the run is alive. If the worker dies, the lease expires on its own, so a crashed worker cannot hold a thread forever. Without Redis the lock is per process.

## Per-user run cap

`runs.max_concurrent_per_user` caps the HTTP graph runs (invoke, stream, AG-UI) that one verified user can have in flight at once. The default is 10, and `0` or `null` means unlimited. Over the cap the request gets 429 before the response starts. Requests without a verified user, which means auth is off, are not capped. Like the thread lock, the count is shared across workers only when Redis is configured.

## Request size limits

Limits on what a client can post are fixed. They have no setting, on purpose.

- Body size: `MAX_REQUEST_SIZE`, 10 MB by default. A declared `Content-Length` over the limit is rejected before any body is read, and a streamed body is cut off as soon as it passes the limit. The upload route has its own limit sized to `MEDIA_MAX_SIZE_MB` (25 by default). WebSocket frames use the same cap.
- Per request: at most 500 messages, 100 content blocks per message and 1,000,000 characters of text across all text blocks.
- AG-UI clients resend the whole conversation on every run. The trailing turn gets the limits above, and the full history gets a looser cap of 10,000 messages.

## Paging and the rate limiter

Checkpointer list endpoints default to 100 rows, cap `limit` at 1000 whatever the client sends, and the core checkpointers lower an explicit `limit` above `MAX_LIST_LIMIT` (also 1000) and raise `ValueError` for a negative `limit` or `offset`. Passing no limit to the Python API still returns everything, which is for code you control, not for a client.

The in-memory rate limiter keeps at most 50,000 keys. It used to sweep every key when a new one arrived at the cap, which blocked the event loop for about 2.5 ms per new key. Keys are now held in least-recently-used order, so the oldest entry is dropped from the front in constant time, and idle-key cleanup runs in the background. The "bucket cap reached" warning is logged at most once a minute. For more than one worker, use the Redis backend.

## What is not bounded

Be clear about the gaps.

- Streaming runs do not have a tool ledger. The ledger lookup is on the invoke path, so a streamed run that is resumed after a crash can repeat a tool that already finished. Use idempotency keys for tools with side effects on streamed runs.
- Node and tool timeouts apply to invoke. For a streaming run, `runs.timeout` is the deadline that holds.
- Counts such as the per-user cap and the thread lock are per process until you add Redis.
- These bounds protect the server. They do not cap what a model costs you. Set provider-side spend limits too.
Loading
Loading