ag-ui-protocol / ag-ui-protocol/ag-ui
[Feature]: Make HttpAgent/AbstractAgent.clone() safe to subclass (cloneInto hook)
- Langage dominant
- Python
- Étoiles
- 15.9k
- Forks
- 1.4k
- Merge moyen
- 1 j 17 h
- PR mergées (30 j)
- 163
Description
### Pre-flight Checklist
- [x] I have searched existing issues and this hasn't been requested yet.
### Problem or Motivation
AbstractAgent.clone() ([agent.ts](https://github.com/ag-ui-protocol/ag-ui/blob/main/sdks/typescript/packages/client/src/agent/agent.ts)) and HttpAgent.clone() ([http.ts](https://github.com/ag-ui-protocol/ag-ui/blob/main/sdks/typescript/packages/client/src/agent/http.ts)) enumerate base-class fields by name and copy them onto a new prototype-preserving object. The prototype is preserved (good — subclass methods survive), but any own properties added by a subclass constructor are silently dropped.
This means every subclass author must override clone() and manually copy their own fields, with no compile-time signal and no runtime warning. The symptom downstream is a TypeError (e.g. this.headersProvider is not a function) re-emitted as RUN_ERROR / INCOMPLETE_STREAM, which is hard to attribute to clone.
This pattern is causing repeated friction in-repo, not just for external consumers:
#1316 (merged, custom fetch) had to add cloned.fetch = this.fetch; to HttpAgent.clone() ad hoc.
#1489 (open, credentials option) — "Properly propagates credentials through constructor and clone()."
#1763 (merged, per-request header forwarding) — all five framework adapters (langgraph, mastra, vercel-ai-sdk, langchain, claude-agent-sdk) had to independently override clone() to "defensive-copy the map so concurrent runs don't interfere." Five separate copies of the same discipline.
#1439 (merged, Python) accepted the same framing on the Python LangGraphAgent.clone() — switched hardcoded LangGraphAgent(...) to type(self)(...) so subclass identity is preserved on per-request clones. The TS equivalent (prototype-preserving via Object.create) is already in place, but the fields side is still ad hoc.
External consumers (e.g. CopilotKit users) hit this when they extend HttpAgent with a headersProvider callback for per-run OAuth refresh, or any other instance state. Clone happens silently in several CopilotKit flows; the field vanishes; the next runAgent() throws.
### Proposed Solution
Add a protected cloneInto(target: this): void hook. clone() becomes a thin wrapper that subclasses don't override; they override cloneInto and call super.cloneInto(target).
```// AbstractAgent
public clone(): this {
const cloned = Object.create(Object.getPrototypeOf(this)) as this;
this.cloneInto(cloned);
return cloned;
}
protected cloneInto(target: this): void {
target.agentId = this.agentId;
target.description = this.description;
target.threadId = this.threadId;
target.messages = structuredClone_(this.messages);
target.state = structuredClone_(this.state);
(target as any)._debug = this._debug;
(target as any)._debugLogger = this._debugLogger;
target.isRunning = this.isRunning;
target.subscribers = [...this.subscribers];
(target as any).middlewares = [...(this as any).middlewares];
target.pendingInterrupts = structuredClone_(this.pendingInterrupts);
}
```
```// HttpAgent
protected cloneInto(target: this): void {
super.cloneInto(target);
target.url = this.url;
target.headers = structuredClone_(this.headers ?? {});
target.fetch = this.fetch;
const newController = new AbortController();
const originalSignal = this.abortController.signal as AbortSignal & { reason?: unknown };
if (originalSignal.aborted) newController.abort(originalSignal.reason);
target.abortController = newController;
}
```
Compat: clone() keeps its current signature; existing subclasses that override clone() (none in core after this change; some in #1763's adapters) continue to work. Migration of in-repo subclasses to cloneInto can be a follow-up — the hook is purely additive.
Subclass authors gain: override cloneInto, call super.cloneInto(target), copy own fields. One method, one rule, mirrors the existing prototype-preservation discipline.
### Alternatives Considered
1. Document the contract only. Add a JSDoc note on clone() telling subclass authors to override it. Cheapest, but #1763's five adapters demonstrate that authors inside the repo also miss this. Documentation alone hasn't been enough.
2. Auto-copy own properties. Object.assign(target, this) after the explicit copies. Works for primitive/reference fields, breaks for fields that need deep-clone semantics (e.g., headers, pendingInterrupts) — would regress current behavior unless paired with a per-field opt-out, which is more complex than the hook.
3. Constructor-based clone (Python #1439 pattern). new (this.constructor as any)(...). Doesn't fit TS HttpAgent cleanly — the constructor takes a config object whose shape may have been transformed since construction (e.g., headers is already structuredClone_'d). Reconstructing config from instance state is fragile.
### Additional Context
Repro:
```class WithHeaderProvider extends HttpAgent {
private getHeaders: () => Record;
constructor(
config: HttpAgentConfig & { getHeaders: () => Record }
) {
const { getHeaders, ...rest } = config;
super({ ...rest, headers: {} });
this.getHeaders = getHeaders;
}
run(input: RunAgentInput) {
this.headers = this.getHeaders();
return super.run(input);
}
}
const a = new WithHeaderProvider({
url: "http://x",
getHeaders: () => ({ Authorization: "Bearer ..." }),
});
const c = a.clone();
await c.runAgent(); // TypeError: this.getHeaders is not a function
```
Related work (for context, not duplicates):
#1316 — first ad-hoc field added to HttpAgent.clone() (fetch)
#1489 — second ad-hoc field (credentials)
#1763 — five adapters duplicate clone-safety logic
#1439 — Python sibling, type(self) fix accepted on the same framing
Happy to PR this. Would the maintainers prefer:
(a) the cloneInto hook as proposed above (additive, no migration required), or
(b) a more minimal change (e.g., document the contract + leave clone() as is)?
Pinging for direction before opening a PR to avoid rework.
Guide de contribution
Ouvrir le guide de contribution
Évaluation
Cette issue n'a pas encore été évaluée.