feat(agents): stubs carry caller context across Agent, Lifecycle Object, and facet RPC; add getStubByName - #2211
Draft
mattzcarey wants to merge 5 commits into
Draft
feat(agents): stubs carry caller context across Agent, Lifecycle Object, and facet RPC; add getStubByName#2211mattzcarey wants to merge 5 commits into
mattzcarey wants to merge 5 commits into
Conversation
getAgentByName() now returns a Proxy over the native stub. Each method call goes through Agent._cf_invoke, which re-enters the SDK invocation context with an AgentCaller record (class, DO id, instance name, or external) and caller-supplied context hints, readable via getCurrentAgent().caller. Each call opens an agents.rpc.call span; the runtime links callee spans itself. The proxy is not a runtime Fetcher, so nativeAgentStub() unwraps it for runtime APIs and RPC arguments; rpc: "native" skips wrapping entirely. Facet stubs (dynamicAgents.get, parentAgent) are not yet contextual. Claude-Session: https://claude.ai/code/session_01Hy6wcN7qjw1ScLkR2aqzjf
… Object Move _cf_invoke and the generic _cf_rpcIdentity out of Agent into lifecycle/rpc-entry.ts. Lifecycle.install defines them, plus __unsafe_ensureInitialized, on the host class prototype once (workerd RPC dispatches prototype members only, never own instance properties), leaving any member the class already declares untouched. getAgentByName accepts any Lifecycle Object; a plain DurableObject with Lifecycle.install now calls and is called with caller context in both directions. Claude-Session: https://claude.ai/code/session_01Hy6wcN7qjw1ScLkR2aqzjf
…dges Facet stubs are ordinary Fetchers on the same JS RPC path as namespace stubs (workerd DurableObjectFacets::get -> FacetOutgoingFactory), so they share every constraint: prototype-only dispatch, no per-call metadata, and AsyncLocalStorage lost on the hop. Wrap them the same way: - dynamicAgents.get / subAgent return a contextual stub. - parentAgent's top-level branch already resolves through getAgentByName; its facet-parent bridge now threads the calling facet's identity through _cf_invokeSubAgentPath at every hop so the parent sees the facet, not the root that relayed the call. - getSubAgentByName resolves the Worker-side caller once and passes it through _cf_invokeSubAgent, so the child sees external, not the parent. - fetch stays the stub's native fetch on every hop. - The workflow origin invoke path shares resolveRpcMethod. Claude-Session: https://claude.ai/code/session_01Hy6wcN7qjw1ScLkR2aqzjf
The default stays the raw Durable Object stub. A contextual stub is a Proxy rather than a runtime Fetcher, so defaulting to it broke every call site that hands a stub to a runtime API or sends it as an RPC argument. AgentRpcOptions is accepted by getAgentByName, dynamicAgents.get, subAgent, parentAgent, and getSubAgentByName. Eviction-only test edits are reverted. A plain Lifecycle Object identifies itself on outbound calls only inside a Lifecycle invocation; a method reached over a raw stub reports external. Pinned by a test and documented. Claude-Session: https://claude.ai/code/session_01Hy6wcN7qjw1ScLkR2aqzjf
🦋 Changeset detectedLatest commit: 1a33449 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
…for the raw stub Drop the rpc option flag, the unwrap helper, and the per-hop caller threading through the facet bridges. getAgentByName and the dynamic-agent helpers always return the wrapped stub; wrapping the bridge proxies lets _cf_invoke travel through them as a method name, so the final object sees the original caller with no plumbing. getStubByName returns the raw Durable Object stub with the same startup guarantee for runtime APIs and RPC arguments. Tests that evict stubs resolve them with getStubByName.
Contributor
🔴 agents import sizesMeasured 288 runtime imports as minified bundles. The primary size is gzip; raw minified size is included for diagnosis. An existing import growing by more than 10% is marked red. This report is informational.
Compared Changed imports (86)
All 288 current runtime imports
Reported by agent-think[bot]. |
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.
Summary
Stubs handed out by the SDK now tell the callee who is calling. Every method call on a stub from
getAgentByName(),dynamicAgents.get(),subAgent(),parentAgent(), orgetSubAgentByName()carries the caller's identity plus optionalcontexthints, readable on the callee asgetCurrentAgent().caller.New
getStubByName()returns the raw Durable Object stub with the same startup guarantee and no caller context, for the cases where the stub must stay a runtimeFetcher(see trade-offs).Why the runtime can't do this yet
Verified against workerd source (
src/workerd/api/worker-rpc.c++,actor-state.c++):callerSpanContexton every call, facet subrequests carry the user span parent). Nothing to do there.AsyncLocalStorageis lost on every RPC hop. So the SDK's own caller identity has to ride in the call.tryGetProperty). That is why the callee entry points are defined on the host class prototype atLifecycle.installtime rather than installed per instance likefetch/alarm.Fetchers on the same RPC path (DurableObjectFacets::get→FacetOutgoingFactory), so they get the same treatment.How it works
agent-stub.ts:wrapAgentStubproxies a stub. Method calls go to_cf_invoke(method, args, caller)on the callee and open anagents.rpc.callspan.id,name,fetch,connect, disposal, JS-internal probes, and_cf_/__unsafe_members pass straight through.lifecycle/rpc-entry.ts:Lifecycle.installdefines_cf_invoke,_cf_rpcIdentity, and__unsafe_ensureInitializedon the host class prototype once, leaving any the class already declares untouched (Agent keeps its facet-aware identity)._cf_invokere-enters the invocation context withcallerattached and uses the same member rule as a native stub. It does not force lifecycle startup, so a re-created instance keeps native startup timing.getAgentByName()accepts any Lifecycle Object, not only Agents.getSubAgentByNamebridge proxies are wrapped the same way._cf_invoketravels through them as an ordinary method name, so no per-hop plumbing was needed: the final object sees the original caller.resolveRpcMethod.getCurrentAgent()gainscaller: AgentCaller | undefined.Trade-offs
Fetcher. It cannot be passed to a runtime API that takes a stub (evictDurableObject,ctx.facets.*) or sent as an RPC argument or return value. Both fail loudly. UsegetStubByName()for those cases. Documented inget-current-agent.md,lifecycle.md,callable-methods.md, and the changeset. In-repo, the 13 test files that evict stubs now resolve them withgetStubByName().external. Pinned by a test and documented.dynamicAgents.getStub()would be the symmetric addition if that comes up.Tests
packages/agents/src/tests/rpc-context.test.ts(18 tests): Worker → Agent, Agent → Agent, plain Lifecycle Object in both directions, cross-kind hops,getStubByNameas a realFetcher(and the wrapped stub rejected byevictDurableObject), error surfacing, member guard parity with native stubs, and the full facet tree (root ↔ child ↔ grandchild viasubAgent,dynamicAgents.get, bothparentAgentbranches, andgetSubAgentByName).Full
--project workersrun green.Related
Prompted by
@durability/transforms(caller/callee transforms over DO RPC). This takes the caller-context idea without the prototype-mutation-by-config or Vite plugin, and keeps a seam that can be replaced if workerd grows a native per-call context channel.