ag-ui-protocol / ag-ui-protocol/ag-ui

[Feature]: Make HttpAgent/AbstractAgent.clone() safe to subclass (cloneInto hook)

Abierto
#1,873 1 comentario 1 reacción 0 asignados Ver en GitHub
enhancement
Lenguaje dominante
Python
Estrellas
15.9k
Forks
1.4k
Merge medio
1 d 17 h
PR fusionados (30 d)
163

Descripción

### 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.

Guía de contribución

Abrir la guía de contribución

Evaluación

Este issue todavía no se ha evaluado.

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.