Repository navigation
feat: client-provided tools over AG-UI (Model A over a retained record) - #880
Merged
Merged
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…void Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…corded first Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed resume to the model Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rter honors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…prefix prepareRouteExecution takes clientTools: on an agent() route each becomes a client_<name> stub (filePath <client:name>), appended after tool scoping and the approve/constrain wrapping, and the preparation bypasses the compiled-agent cache, which is keyed without tools. Any other route kind refuses them. An authored or capability tool named client_* fails route preparation. streamResolvedRoute forwards clientTools to preparation and clientToolRecorder to the adapter, which sets configurable[CLIENT_TOOL_RECORDER_KEY] after route params so a URL param cannot shadow it. materializeResolvedRouteGraph takes clientTools so a caller can rebuild the parked run's graph. RouteResumePayload widens internally to carry a ClientToolResumeValue; the HTTP resume body is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ent's tool name A client tool call parks with interrupt() like a permission prompt, but the client answers it with a role:"tool" message on its next run. So: - normalizeB4Stream drops client-tool-call interrupt chunks (the turn still records as parked upstream in observeInterrupts) and ends with an ordinary RUN_FINISHED; it un-prefixes client_<n> to <n> on tool_call, tool_result and tool_call_args when <n> is one of this run's client tools (default: none, so behaviour is unchanged until the handler passes the set). - GET /threads/:id/pending_interrupts and the attach state frame omit client parks. - POST /threads/:id/resume and the AG-UI resume path exclude client parks before the exact-set match and the grant gate: naming one is stale (alone) or a set mismatch, and a permission park pending beside one is answerable on its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed; keep parks off the live tap - POST /threads/:id/resume answers 409 client_tool_pending whenever the pending set holds a client tool park, alone or beside a permission park. A partial resume of the permission parks alone is unsafe: the answered park's __interrupt__ write survives until the superstep completes (so it is re-demanded), and the client task re-runs on this path with no client stubs bound, so ToolNode resolves the park destructively. - The AG-UI handler keeps its interim filter, now documented: mixed parks must be resolved in ONE resume map covering every pending park. - tapLiveTurn publishes the client's view through the same helper as normalizeB4Stream: no client park frames, un-prefixed client tool names. - withoutClientToolParks recognises a client park by type alone, and fails closed (malformed) on a client-typed envelope missing its ids. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l call; pin the record's defences Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
closeAbandonedClientToolCalls writes one ToolMessage per parked client call through graph.updateState(..., "tools"). LangGraph folds the parked superstep's successful pending writes in first and drops its interrupt writes, so a sibling server tool's result survives, ahead of ours, and no park remains. No resume key is needed, so an unanswerable park closes the same way. A new user message then runs as an ordinary turn; with no new message, CONTINUE_AFTER_CLOSE (an empty resume map) lets the model reply. Refuses, before writing, a pending non-client park, a thread with no client park, and any call set that does not name exactly the latest assistant turn's unresolved client_ calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The close captures the checkpoint id with the parks and refuses to write unless the state read sees the same one; after the write, the new checkpoint's parent must be that id. A close_incomplete after the write means the thread was mutated: callers must not retry or run the turn. Each call's name must be client_<toolName> of the unresolved call it closes. Error messages are fixed strings and never echo client ids. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rip) POST /agui/:routeId now issues, matches and resumes client tool calls (#743, Model A): - server.agui.clientToolTtlMs (default 600000, positive integer <= 1 year, anything else fails the boot) and server.agui.clientToolStore; node fallback resolveClientToolCallStore opens .b4/client-tool-calls.sqlite only when some route opts in. Boot-resolved into a ClientToolRuntime. - The AG-UI body is read through readBoundedText (8 MiB, 413). - Opted-in tools are validated (422 B4_E5401) and preflighted with checkRouteClientToolsSupport (422 client_tools_not_supported); no store -> 503 client_tool_store_unavailable, for new tools and for pending parks. - A trailing tool message on an opted-in route counts as resuming; resumes and any turn on a thread with a client park pending take the resume claim before the snapshot the decision is made on. - resolveClientToolTurn over the full snapshot: none -> normal turn with the per-request recorder and client tool names wired; partial -> RUN_STARTED + RUN_FINISHED without touching the graph; resume -> one Command covering the client results and any approval decisions; abandon -> INTERIM 409 client_tool_pending (12b closes the calls). - Results over 64 KiB for an outstanding park -> 413 before any answer. - Parked stubs are rebuilt when a follow-up omits `tools`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…replay-only Review fixes for the AG-UI client tool round trip (#743): - A route that does not take client tools can no longer answer, resume or re-offer another route's client park: a pending client park there is 409 client_tool_pending. A trailing role:"tool" message now counts as resuming on EVERY AG-UI route, so a thread-access step-up applies. - Stubs rebuilt for parked calls are replay-only: createClientToolStub takes { replayOnly }, and a NEW call to such a stub returns CLIENT_TOOL_UNAVAILABLE_RESULT without gating, recording or parking. - On an opted-in route, a trailing tool message that answers nothing pending is a RUN_STARTED/RUN_FINISHED no-op instead of re-running the last user turn, which also makes client retries idempotent. - A request decided without the resume claim re-reads the snapshot under the run slot and refuses (409 client_tool_pending) if a client park appeared in between. - resolveClientToolCallStore reopens an existing .b4/client-tool-calls.sqlite after the opt-in is removed, so parked threads are not bricked. - Docs: thread-access `resuming` covers the trailing tool message; API table lists the two new core exports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…urn decision The post-begin recheck was skipped for any request holding the resume claim. Since a trailing role:"tool" message now takes the claim on every route, a claim-holding request could decide a new turn while another run was still executing, and run past the client park that run left behind. The recheck now follows the decision (no client park seen), not the claim. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A run that does not answer a parked client tool call no longer gets the interim 409 client_tool_pending. On an opted-in route, a new user message, an expired call or an unanswerable park abandons the parked calls: under the run slot, the calls are closed in the checkpoint on a graph built for the route that parked them (answered calls keep their stored result, the rest get ABANDONED_CLIENT_TOOL_RESULT), the abandoned records are voided only after the close, and the turn runs — the new user message, or a continuation so the model replies to the closed calls. A permission park pending alongside is 409 resume_required. Close failures map to fixed messages: 409 client_tool_close_failed, 500 client_tool_close_incomplete (state may be mutated; the turn does not run), or a generic 500. A route that is not opted in clears the dead end the same way on a new user message, using only stored results (it never answers a tool message); a trailing tool message, or a permission park alongside, stays 409 client_tool_pending. Settling: the AG-UI handler now voids superseded approval grants as the Agent Protocol handlers do, and a turn that settles without parking voids the thread's outstanding client tool records (keeping any whose park is still pending). Recorder: has() is true only for a live record — outstanding, or answered and resumed by this request — and record() refuses an id that already has a record, so a provider-reused id can never park on an old answered or voided record; the model sees a tool error instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the turn's instances Review fixes for the AG-UI abandon (Task 12b): - Only the route that PARKED a client call may answer or resume it. Before anything is stored, the opted-in branch reads the thread's parked_route; on any other route the request is treated like a non-opted one: tool messages are never answered, a trailing user message abandons (closing on the parked route's graph), anything else is 409 client_tool_pending. - The close materializes the parked route with the same route-execution instances the turn uses (permissions and memory stores, route manifest, static modules, sandbox manager, thread id and store, run signal), so it no longer needs node fallbacks and matches the graph that parked. materializeResolvedRouteGraph accepts a threadId for this. - The request's middleware context is reused for the close only when the parked route is this request's route; a missing or unresolvable parked_route is logged before the fail-closed 409. - The settle-time client record void runs only once the route source has settled: inline for a drained or failed turn, after the source unwinds (and before the run slot is released) for an aborted one. - Tests: cross-route answer refused and cross-route abandon, the close with per-route fallbacks armed to throw, close_incomplete and generic-500 fault injection, and the keep-still-parked filter of the settle void. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`parked_route` is written only once a turn settles, after RUN_FINISHED has reached the client, so it cannot decide who may answer a client tool call: a fast same-route client was refused, a stale value let another route answer, and a park landing on the abort path was never answerable. ClientToolCallRecord gains `routeId`, the route key whose run issued the call, written by the per-request recorder while the call is issued — before the client can see it. The SDK memory store, the SQLite store and the Postgres store carry it (`route_id` NOT NULL, no default; migration 1 is edited in place because it has not shipped, and every INSERT names all eleven columns). The AG-UI handler answers or resumes client parks only when no parked call's record names another route; otherwise the request is handled like one on a non-opted route (tool messages ignored, a trailing user message abandons, anything else is 409 client_tool_pending), decided before anything is stored. The abandon's close materializes the route on the parks' records (they must agree), falling back to `parked_route` only when no parked call has a record at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t_ prefix Route preparation already refuses a tool named client_*; b4 check now reports an authored one statically, under B4_E1001, for every agent route whether or not it declares a tool scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he changeset - ag-ui: a Client-provided tools section (round trip, abandonment, limits, threat model, storage, error codes); the CopilotKit callout now says frontend tools 422 unless the route opts in, and that toolChoice is accepted but not interpreted - configuration: server.agui clientTools/clientToolTtlMs/clientToolStore - permissions: the reserved clientTool key (exact, allow by default, deny refuses, never inherits tool:<name>, no always); api/permissions behaviour contract permissions.clientTool.exact - approval-grants: grants cover approval parks only - cli: b4 check reports client_* tool names - spec §3 failure table revised to what shipped - changeset: patch for sdk, core, langchain, permissions, cli, sqlite-storage, postgres-storage Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… answers Every AG-UI client resends its history, so refusing any request that still carried an over-cap result made the 413 permanent: every later run on the thread, including a new user message after the TTL, was refused. The 413 now applies only when the last message is a tool message and an over-cap one answers an outstanding, unexpired parked call. Otherwise the over-cap messages are dropped before the resolver (which stores answers before deciding), so the call reads as unanswered and is abandoned; the content never reaches the store or the model. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- abandoned calls are written straight into the checkpoint (no tool-result frame, no offloading or stream transformers); expiry is checked on next use; a result stored before expiry is used, a later one discarded - an approval and a client call parked together are answered in one run; any route can abandon; a non-issuing route over a pending approval gets client_tool_pending - a trailing tool message that matches nothing is an empty run - the 413 applies only when answering; otherwise the over-cap result is dropped - the definitions budget is 32,768 characters, results 64 KiB of UTF-8 - copilotkitSuggest parks like any client tool on an opted-in route Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /threads/:id/runs/stream and /runs/wait now answer 409 client_tool_pending while a client tool call is parked, read under the run slot after the thread-access gate. A new run's input made LangGraph discard the park, leaving the model's tool call with no ToolMessage: every later model call on the thread was rejected by the provider, and no park remained for an AG-UI abandon to close. /resume shares the refusal's fixed message, which now names answering or abandoning over AG-UI. Also reuse clientToolCallIds() for the resuming ids and the settle-time void instead of inline copies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, raw state Also list the remaining behaviour changes in the changeset: AG-UI turns void superseded grants at settle, a no-op trailing tool message, the resume claim on a trailing tool message, and the Agent Protocol run refusal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The run endpoints now read pending state for a parked client call, so the fixture's read-once assertion ignores reads made while seeding the thread's route. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
blove
temporarily deployed
to
vercel-preview
September 30, 2026 21:54 — with
GitHub Actions
Inactive
This was referenced Oct 1, 2026
blove
added a commit
that referenced
this pull request
Oct 3, 2026
…#909) * docs: design for generalizing the tool-call record to every tool call Follow-up to #880 (§7, open question 7): server rows as identity only, scope limited to runs with a resolved store, per-thread prune with a configurable retention, and pendingToolCallIds derived from the record. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: state why the kind column carries a backfill-only default Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: implementation plan for generalizing the tool-call record Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: plan self-review fixes (column order, fixture route) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: prune runs under the run slot, after it is taken Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(sdk): tool-call record carries a kind and a settle; store gains settle and prune Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: plan — SQL stores share the SDK's canonical-before rule for prune Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(sqlite-storage): tool-call record migration 2 (kind, settled_at), settle and prune Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(postgres-storage): tool-call record migration 2 (kind, settled_at), settle and prune Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(core): client tool stubs carry a clientTool marker Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(langchain): record every server tool call through the run's recorder Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: spec — task calls are not recorded; a permission park does not settle Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(langchain): a permission park does not settle the server row A gate that parks by throwing GraphInterrupt is issued but not settled; the resumed re-execution issues again (no-op on the key) and settles when the tool really returns or throws. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(ag-ui): pendingToolCallIds may be supplied asynchronously Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(ag-ui): a rejecting pendingToolCallIds read ends the run as RUN_ERROR Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(cli): server.agui.toolCallRetentionMs, and the store shape check requires settle and prune Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(cli): the turn resolver states the record invariant; partial carries no ids Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(cli): optional chain in answeredResult guard Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(cli): the server-kind guard in the turn resolver is exercised on its own Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(cli): record every tool call on a run with a store, prune per thread, derive pendingToolCallIds from the record Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: spec — pending ids scoped to the request's parks; prune cutoff never shorter than the TTL Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(cli): prune never outruns the TTL; pendingToolCallIds scoped to this run's parks Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: the tool-call record covers every tool call; toolCallRetentionMs Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * chore(web): regenerate SEO lastmod for the AG-UI and configuration docs Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs: name the leftover-store case, refresh stale recorder comments, note rolling upgrades Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(web): regenerate SEO lastmod for the AG-UI docs Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(web): regenerate SEO lastmod after merging main Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: spec — pruning reconciled with #898 at merge Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: server rows in the prune docs; sdk record row wording; sweep test comment Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(web): regenerate SEO lastmod for the cli, ag-ui and sdk api docs Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(web): regenerate SEO lastmod after merging main Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #743. Implements the design in #744 (
docs/superpowers/specs/2026-09-18-client-provided-tools-design.md) with your §12 decisions. Implementation plan:docs/superpowers/plans/2026-09-30-client-provided-tools.md.What it does
A route named in
server.agui.clientToolsnow runs the tools an AG-UI client sends, instead of just tolerating them.client_<name>.RUN_FINISHED. It runs the tool and sends the result as arole: "tool"message on its next run. This is the flow existing CopilotKit and AG-UI clients already use.interrupt(), and the server keeps a durable record of it. It is never shown to the client and carries no credential.@b4run/sdkClientToolCallStore,ClientToolRecorder,createMemoryClientToolCallStore@b4run/sqlite-storage/@b4run/postgres-storage.b4/client-tool-calls.sqlite.@b4run/coreclient_<name>stub. It checks theclientToolpermission once before the park, records the call, then parks.replayOnlyrebuilds the stub for a parked call.@b4run/permissionsclientToolis an exact-match key and never inheritstool:<name>@b4run/langchainrunreceives the provider's tool-call id@b4run/clib4 checkreservesclient_*Configuration:
server.agui.clientToolTtlMs(default 10 min) andserver.agui.clientToolStore.Where the implementation departs from the spec
The spec's §3 table has been updated to match.
ToolMessageinto the checkpoint (updateStateas thetoolsnode). Results from sibling tools that finished are kept. The new user message then runs as an ordinary turn. A plain resume would have discarded that message.routeId), not read fromparked_routemetadata. That metadata is written afterRUN_FINISHED, which raced with fast clients and went stale.Security review
Every task had a spec review and a code-quality review, and the security-critical ones also had a mutation check. Each of these guards fails at least one test when removed:
clientToolexact match, anddenywinning over atool:allowbypassCachefor per-run tools/runs/*and/resumerefusals while a client call is parkedA final review of the whole branch found one cross-cutting gap, fixed here: an Agent Protocol run could silently discard a parked client call and leave the thread permanently broken.
Behaviour changes (in the changeset)
client_*are reserved; a route defining one fails preparation, andb4 checkreports it.resumingfor thread-access policies, and it takes the thread's resume claim./threads/:id/runs/stream,/runs/waitand/resumereturn 409client_tool_pending.Follow-ups (not in this PR)
Test plan
pnpm build,pnpm typecheck,pnpm lint,node scripts/check-docs.mjs,node scripts/check-changesets.mjs,seo:lastmod:check/:routes@b4run/clisuite: 2524 passed, 4 skipped. The client-tool and resume suites pass, 185/185.@b4run/core,@b4run/sdk,@b4run/sqlite-storage,@b4run/permissions,@b4run/langchain,apps/webB4_TEST_PGSTORAGE=1 @b4run/postgres-storage: 141/141 on real Postgres, including concurrent single-winner answers and answer-vs-void racescopilotkit-examples-e2eandharness-verify🤖 Generated with Claude Code