Skip to content

Commit 7e69300

Browse files
committed
fix(runtime): address CR loop round 1 findings
Source fixes: - Fix TOCTOU race in concurrent run guard — set abortController synchronously before Observable creation, closing the window where two run() calls could both pass the guard - Add JSON.stringify try/catch in TanStack TOOL_CALL_RESULT handler (matching existing aisdk.ts protection against circular refs) - Use role allowlist (user/assistant/tool) instead of denylist in convertInputToTanStackAI to exclude activity/reasoning roles - Change error+abort handler from break to return in aisdk converter to stop processing after error during abort - Fix version-pinned comments (remove "AI SDK 5.0" reference) Test fixes: - Add timedOut flag to collectEventsIncludingErrors to distinguish hung observables from normal completion - Add clearTimeout on success/error to prevent timer leaks - Remove timing-dependent setTimeout in concurrent run guard test Docs fixes: - Split imports: copilotRuntimeNextJSAppRouterEndpoint from @copilotkit/runtime, Agent/converters from @copilotkit/runtime/v2 - Fix forwardedProps example: use CopilotKit properties prop - Fix useCopilotChat → useCoAgent for state management reference - Fix model syntax colon → slash (openai/gpt-4o)
1 parent 4e33d42 commit 7e69300

6 files changed

Lines changed: 53 additions & 31 deletions

File tree

docs/snippets/shared/backend/custom-agent.mdx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The `Agent` class gives you full control over the LLM call. You provide a factor
88
| | `BuiltInAgent` | `Agent` |
99
|---|---|---|
1010
| **Setup** | Minimal — pass a model string, CopilotKit handles the rest | You own the LLM call and stream |
11-
| **Model resolution** | Built-in (`"openai:gpt-4o"`) | You set up the model yourself |
11+
| **Model resolution** | Built-in (`"openai/gpt-4o"`) | You set up the model yourself |
1212
| **Tools, MCP, state tools** | Automatically wired | You wire them in your factory |
1313
| **AI SDK support** | Vercel AI SDK only | Any backend: AI SDK, TanStack AI, or custom |
1414
| **Best for** | Quick setup, standard use cases | Full control, non-standard backends, async initialization |
@@ -27,6 +27,8 @@ You have an existing LLM backend and you want a CopilotKit copilot using it. Pic
2727
import {
2828
CopilotRuntime,
2929
copilotRuntimeNextJSAppRouterEndpoint,
30+
} from "@copilotkit/runtime";
31+
import {
3032
Agent,
3133
convertMessagesToVercelAISDKMessages,
3234
} from "@copilotkit/runtime/v2";
@@ -62,6 +64,8 @@ export const POST = async (req: NextRequest) => {
6264
import {
6365
CopilotRuntime,
6466
copilotRuntimeNextJSAppRouterEndpoint,
67+
} from "@copilotkit/runtime";
68+
import {
6569
Agent,
6670
convertInputToTanStackAI,
6771
} from "@copilotkit/runtime/v2";
@@ -100,8 +104,8 @@ export const POST = async (req: NextRequest) => {
100104
import {
101105
CopilotRuntime,
102106
copilotRuntimeNextJSAppRouterEndpoint,
103-
Agent,
104-
} from "@copilotkit/runtime/v2";
107+
} from "@copilotkit/runtime";
108+
import { Agent } from "@copilotkit/runtime/v2";
105109
import { EventType, type BaseEvent } from "@ag-ui/client";
106110
import { NextRequest } from "next/server";
107111

@@ -483,15 +487,17 @@ const agent = new Agent({
483487
</Tab>
484488
</Tabs>
485489

486-
Forward props from the frontend using `useCoAgent` or `CopilotChat`:
490+
Forward props from the frontend using the `CopilotKit` provider's `properties` prop:
487491

488492
```tsx title="app/page.tsx"
489-
<CopilotChat forwardedProps={{ model: "anthropic/claude-sonnet-4", temperature: 0.3 }} />
493+
<CopilotKit properties={{ model: "anthropic/claude-sonnet-4", temperature: 0.3 }}>
494+
<CopilotChat />
495+
</CopilotKit>
490496
```
491497

492498
### With State Tools
493499

494-
`Agent` does not inject state management tools automatically. If your app uses shared state (`useCopilotChat` with state), add the tools yourself:
500+
`Agent` does not inject state management tools automatically. If your app uses shared state (`useCoAgent` with state), add the tools yourself:
495501

496502
<Tabs items={["AI SDK", "TanStack AI"]}>
497503
<Tab value="AI SDK">

packages/runtime/src/agent/__tests__/agent-test-helpers.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,8 @@ export interface CollectedEventsResult {
282282
events: BaseEvent[];
283283
/** Whether the observable completed via error (true) or normal completion (false) */
284284
errored: boolean;
285+
/** Whether the safety timeout fired (indicates a hung observable) */
286+
timedOut: boolean;
285287
}
286288

287289
/**
@@ -294,17 +296,21 @@ export async function collectEventsIncludingErrors(
294296
): Promise<CollectedEventsResult> {
295297
return new Promise((resolve) => {
296298
const events: BaseEvent[] = [];
299+
const timeoutId = setTimeout(() => {
300+
subscription.unsubscribe();
301+
resolve({ events, errored: false, timedOut: true });
302+
}, 5000);
297303
const subscription = observable.subscribe({
298304
next: (event) => events.push(event),
299-
error: () => resolve({ events, errored: true }),
300-
complete: () => resolve({ events, errored: false }),
305+
error: () => {
306+
clearTimeout(timeoutId);
307+
resolve({ events, errored: true, timedOut: false });
308+
},
309+
complete: () => {
310+
clearTimeout(timeoutId);
311+
resolve({ events, errored: false, timedOut: false });
312+
},
301313
});
302-
303-
// Prevent hanging tests
304-
setTimeout(() => {
305-
subscription.unsubscribe();
306-
resolve({ events, errored: false });
307-
}, 5000);
308314
});
309315
}
310316

packages/runtime/src/agent/__tests__/agent.test.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -580,13 +580,10 @@ describe("Concurrent run guard", () => {
580580
});
581581
const input = createDefaultInput();
582582

583-
// Start first run
583+
// Start first run — abortController is now set synchronously in run()
584584
const sub = agent.run(input).subscribe({ next: () => {} });
585585

586-
// Give the async IIFE time to set abortController
587-
await new Promise((r) => setTimeout(r, 10));
588-
589-
// Second run should throw
586+
// Second run should throw immediately (no timing dependency)
590587
expect(() => agent.run(input)).toThrow("Agent is already running");
591588

592589
// Cleanup

packages/runtime/src/agent/agent.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,18 +85,20 @@ export class Agent extends AbstractAgent {
8585
);
8686
}
8787

88+
// Set synchronously before Observable creation to close the TOCTOU window
89+
// between the guard check above and the subscriber callback.
90+
this.abortController = new AbortController();
91+
const controller = this.abortController;
92+
8893
return new Observable<BaseEvent>((subscriber) => {
89-
// Emit RUN_STARTED synchronously
94+
// Emit RUN_STARTED before entering the async factory
9095
const startEvent: RunStartedEvent = {
9196
type: EventType.RUN_STARTED,
9297
threadId: input.threadId,
9398
runId: input.runId,
9499
};
95100
subscriber.next(startEvent);
96101

97-
this.abortController = new AbortController();
98-
const controller = this.abortController;
99-
100102
const ctx: AgentFactoryContext = {
101103
input,
102104
abortController: controller,

packages/runtime/src/agent/converters/aisdk.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ export async function* convertAISDKStream(
179179
}
180180

181181
case "text-delta": {
182-
// In AI SDK 5.0, the property is 'text'
182+
// AI SDK text-delta events use 'text' (not 'delta')
183183
const textDelta = "text" in p ? (p.text as string) : "";
184184
const textEvent: TextMessageChunkEvent = {
185185
type: EventType.TEXT_MESSAGE_CHUNK,
@@ -242,7 +242,7 @@ export async function* convertAISDKStream(
242242
}
243243

244244
case "tool-result": {
245-
// AI SDK uses "output" (v5+), but older versions used "result" — check both
245+
// AI SDK tool-result uses "output"; older versions used "result" — check both
246246
const toolResult =
247247
"output" in p ? p.output : "result" in p ? p.result : null;
248248
const toolName = "toolName" in p ? (p.toolName as string) : "";
@@ -302,7 +302,7 @@ export async function* convertAISDKStream(
302302

303303
case "error": {
304304
if (abortSignal.aborted) {
305-
break;
305+
return;
306306
}
307307
// Re-throw so the caller can emit RUN_ERROR
308308
const err = p.error ?? p.message ?? p.cause;

packages/runtime/src/agent/converters/tanstack.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,12 @@ export interface TanStackInputResult {
4747
export function convertInputToTanStackAI(
4848
input: RunAgentInput,
4949
): TanStackInputResult {
50+
// Allowlist: only pass user/assistant/tool messages to TanStack.
51+
// Other roles (system, developer, activity, reasoning) are either
52+
// extracted into systemPrompts or not applicable.
53+
const chatRoles = new Set(["user", "assistant", "tool"]);
5054
const messages: TanStackChatMessage[] = input.messages
51-
.filter((m: Message) => m.role !== "developer" && m.role !== "system")
55+
.filter((m: Message) => chatRoles.has(m.role))
5256
.map((m: Message): TanStackChatMessage => {
5357
const msg: TanStackChatMessage = {
5458
role: m.role as "user" | "assistant" | "tool",
@@ -148,15 +152,22 @@ export async function* convertTanStackStream(
148152
};
149153
yield endEvent;
150154
} else if (type === "TOOL_CALL_RESULT") {
155+
let serializedContent: string;
156+
if (typeof raw.content === "string") {
157+
serializedContent = raw.content;
158+
} else {
159+
try {
160+
serializedContent = JSON.stringify(raw.content ?? raw.result ?? null);
161+
} catch {
162+
serializedContent = "[Unserializable tool result]";
163+
}
164+
}
151165
const resultEvent: ToolCallResultEvent = {
152166
type: EventType.TOOL_CALL_RESULT,
153167
role: "tool",
154168
messageId: randomUUID(),
155169
toolCallId: raw.toolCallId as string,
156-
content:
157-
typeof raw.content === "string"
158-
? raw.content
159-
: JSON.stringify(raw.content ?? raw.result ?? null),
170+
content: serializedContent,
160171
};
161172
yield resultEvent;
162173
}

0 commit comments

Comments
 (0)