fix: allow interfaces as agent props - #2238
Conversation
Interfaces do not receive implicit string index signatures, so the Record<string, unknown> bound rejected otherwise valid props types. Use object bounds across Agent, Lifecycle, routing, AIChatAgent, and legacy McpAgent while retaining the existing Record default. Fixes #1886
🦋 Changeset detectedLatest commit: 72e0915 The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 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 |
⚪ agents import sizesMeasured 336 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 No import sizes changed. All 336 current runtime imports
Reported by agent-think[bot]. |
| T extends Agent<Env, unknown, object> = Agent<Env>, | ||
| Props extends object = Record<string, unknown> |
There was a problem hiding this comment.
🟡 Typed namespaces accept unrelated props
With a typed namespace, getAgentByName infers Props solely from options and accepts fields unrelated to the agent. onStart then receives a value violating its declared props contract.
Learn more
The namespace type identifies the concrete Agent class, including its startup props type. The separate Props parameter is inferred from options, so it never checks those options against the namespace's Agent type. A structurally unrelated object can therefore initialize an Agent while TypeScript reports no error.
Example: For class RolePlayAgent extends Agent<Env, unknown, { role: string }>, getAgentByName(rolePlayNamespace, "role-play", { props: { wrong: 1 } }) type-checks. onStart receives { wrong: 1 } although its declaration requires role.
Recommended fix: Derive the options props type from T instead of inferring an independent Props, or constrain the independent parameter to the props type extracted from T. Add a negative type test using a typed namespace and incompatible props.
Was this helpful? React with 👍 or 👎 to provide feedback.
| export async function routeAgentRequest< | ||
| Env, | ||
| Props extends object = Record<string, unknown> | ||
| >( |
There was a problem hiding this comment.
🟡 Callable props crash request routing
When Props is callable, routeAgentRequest accepts it and JSON.stringify returns undefined. encodeLifecycleProps then throws before the request reaches the agent.
Learn more
TypeScript's object type includes functions, arrays, and built-in objects, not only named-field records. Routed startup props are serialized as root JSON values by encodeLifecycleProps. JSON.stringify returns undefined for a root function, which is not a valid input for the following encoding path.
Example: routeAgentRequest(request, env, { props: () => "role" }) type-checks after this change. At runtime, serialization produces undefined and routing fails instead of invoking the Agent.
Recommended fix: Preserve interface compatibility while excluding callable and other unsupported root shapes from public props types. Also validate the serialization result before encoding, and add negative type tests for callable props plus runtime coverage for unsupported routed values.
Was this helpful? React with 👍 or 👎 to provide feedback.
| Env extends Cloudflare.Env = Cloudflare.Env, | ||
| State = unknown, | ||
| Props extends Record<string, unknown> = Record<string, unknown> | ||
| Props extends object = Record<string, unknown> |
There was a problem hiding this comment.
🟡 Think still rejects interface props
A Think subclass using named-field interface props still fails compilation. The Think props constraint retains the rejected Record<string, unknown> bound.
Learn more
Think is a public subclass of Agent with the same Env, State, Props generic shape. Its own bound is stricter than the relaxed base-class bound, so the interface support stops at that subclass.
Example: interface Config { role: string } works with Agent<Env, unknown, Config> and AIChatAgent<Env, unknown, Config>, but Think<Env, unknown, Config> still reports that Config lacks a string index signature.
Recommended fix: Apply the compatible props constraint to Think, add a Think type-level regression, and include @cloudflare/think in the changeset because its public declaration changes.
Was this helpful? React with 👍 or 👎 to provide feedback.
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
| Env extends Cloudflare.Env = Cloudflare.Env, | ||
| T extends Agent<Env> = Agent<Env>, | ||
| Props extends Record<string, unknown> = Record<string, unknown> | ||
| T extends Agent<Env, unknown, object> = Agent<Env>, |
There was a problem hiding this comment.
this should define a relation between the Agents props and the Props template type of getAgentByName. Otherwise, typescript will not enforce passing compatible Props, loosing the benefits of type-safety.
|
|
||
| declare const config: RolePlayAgentConfig; | ||
| declare const namespace: DurableObjectNamespace<RolePlayChatAgent>; | ||
| getAgentByName(namespace, "role-play", { props: config }); |
There was a problem hiding this comment.
a separate test should be added, which should call getAgentByName with props being an object missing some properties of the RolePlayAgentConfig interface. This test should expect a TS error on such call to prove it detects such type mismatch.
| } | ||
|
|
||
| declare const rolePlayNamespace: DurableObjectNamespace<RolePlayAgent>; | ||
| getAgentByName(rolePlayNamespace, "role-play", { props: config }); |
There was a problem hiding this comment.
a separate test should be added, which should call getAgentByName with props being an object missing some properties of the RolePlayAgentConfig interface. This test should expect a TS error on such call to prove it detects such type mismatch.
Summary
PropsforAgent,AIChatAgent, legacyMcpAgent, andLifecycle.objectbound togetAgentByName,AgentGetOptions, and request-routing options.Record<string, unknown>as the default, so untyped callers still read arbitrary props asunknown.getAgentByName(namespace, name, { props: config })report, typed Agent namespaces, routing, Lifecycle, MCP, and AI Chat. Primitive props remain rejected.Why this is still needed
PR #1906 was closed in favor of changing the bound in PartyServer. Since then,
Agentstopped extending PartyServer and now composes the SDK-ownedLifecycle, where the sameRecord<string, unknown>constraint remained. The upstream PartyServer change no longer fixes this API by itself.Proof
Before the fix, the new regression produced the reported error on current
main:After the fix:
agentsand@cloudflare/ai-chatpackage entry points, using an interface as props without casts or an index signature.pnpm run checkpassed, including all 119 TypeScript projects.pnpm exec nx affected -t test --base=origin/mainpassed for 22 projects. This included 3,195 Agents tests, 735 AI Chat tests, and 898 Think tests.This is a type-only change with no runtime behavior change.
Fixes #1886