Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,12 @@ install instructions, usage, and API docs.
| [`@coder/ai-sdk-agent`](./packages/agent) | [![npm](https://img.shields.io/npm/v/@coder/ai-sdk-agent.svg)](https://www.npmjs.com/package/@coder/ai-sdk-agent) | A Vercel AI SDK–compliant **`Agent`** (AI SDK v6) backed by **Coder Agents**, Coder's server-side agent runtime. `new CoderAgent()` returns a real `Agent` — `generate()`, `stream()`, tool calls, the whole interface. |
| [`@coder/ai-sdk-provider`](./packages/provider) | [![npm](https://img.shields.io/npm/v/@coder/ai-sdk-provider.svg)](https://www.npmjs.com/package/@coder/ai-sdk-provider) | A **Vercel AI SDK provider** that routes `generateText` / `streamText` calls through your Coder deployment's [AI Gateway](https://coder.com/docs/ai-coder/ai-gateway). Point it at your deployment with a Coder API token and use any model it proxies — no raw provider keys, with per-user auth and audit. |

**Which package?** Need a **model** (text, streaming, or schema‑constrained
structured output) through your deployment → `@coder/ai-sdk-provider`. Need Coder's
**server‑side agent** (multi‑step tool loop, MCP, workspace file/shell tools) →
`@coder/ai-sdk-agent`. Need to run a **CLI coding agent** (Claude Code, Codex)
inside a workspace → `@coder/ai-sdk-sandbox`.

## Contributing

Development setup, the command reference, and how releases work all live in
Expand Down
175 changes: 158 additions & 17 deletions packages/agent/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,25 @@ context compaction. The Vercel AI SDK runs its loop **client‑side**. This pack
bridges the two, so a Coder agent looks and feels like a native AI SDK agent without
re‑implementing the loop.

## Agent vs. provider — which package?

Two packages, two jobs:

- **`@coder/ai-sdk-agent` (this package)** — Coder's **server‑side agent**: the
multi‑step tool loop, built‑in tools, MCP servers, workspace‑scoped file/shell
tools, sub‑agents, and compaction all run on the deployment. Each `CoderAgent`
is one server chat ("session") and may provision a workspace. Reach for it when
you need **server‑side tools, MCP, or a workspace**.
- **[`@coder/ai-sdk-provider`](../provider)** — **plain model calls** through
Coder's AI Gateway. A normal AI SDK provider: `generateText`, `streamText`, and
**`generateObject` for schema‑constrained structured output**. No chat, no
workspace, natively cancelable. Reach for it when you just need **a model**
(plan / extract / summarize / classify) with no server‑side tools.

Rule of thumb: **need server‑side tools, MCP, or a workspace → Agent; need a model
→ provider.** They compose — a multi‑step pipeline often uses the provider for its
pure text/JSON steps and the Agent only for the steps that touch tools.

## Install

```bash
Expand DownExpand Up@@ -182,31 +201,131 @@ One `CoderAgent` instance maps to one chat ("session") on the Coder server. The
created on the first turn and reused for subsequent `generate()`/`stream()` calls (multi‑turn
conversation with server‑side history). `agent.chatId` is the current chat id.

- `agent.resetSession()` — start a fresh chat on the next turn.
- `agent.resetSession()` — start a fresh chat on the next turn (reuse one instance for sequential turns; you don't need a new agent per turn).
- `agent.interrupt()` — interrupt an in‑flight generation.
- `agent.archive()` — archive the underlying chat (cleanup).
- `agent.archive()` — archive the underlying chat (cleanup; see [Cleanup](#cleanup)).
- `agent.listModels()` — list the deployment's model configs, so you don't have to guess the `model` hint.
- Resume a prior chat: `new CoderAgent({ …, chatId: "…" })`.

A single instance is **single‑flight** — don't run concurrent generations against it.
A single instance is **single‑flight** — don't run concurrent generations against it. For concurrency, use one instance per session (and see [Workspaces & quota](#workspaces--quota)).

## Timeouts & cancellation

Pass an `abortSignal` to `generate()`/`stream()` to cancel a turn. Aborting
**interrupts the server‑side run** (not just the local socket), so the chat stops
generating and releases its resources instead of running on, orphaned. Tearing
down a `stream()` early (cancelling the stream) interrupts the run too.

For a hard ceiling, set `requestTimeoutMs`. If a segment runs longer (e.g. the
server is wedged, or a workspace can't be scheduled), the run is interrupted and
the call rejects with a retryable `CoderChatError` (`kind: "timeout"`) instead of
hanging:

```ts
const agent = new CoderAgent({ /* … */ requestTimeoutMs: 120_000 });
```

`requestTimeoutMs` bounds **each server segment** — one model round‑trip until it
settles or pauses for a client tool. A multi‑step `generate()` that drives client
tools runs several segments, so it bounds each one, not the whole call. To cap the
**total** wall‑clock of a multi‑step call, pass a deadline as the signal instead:

```ts
await agent.generate({ prompt: "…", abortSignal: AbortSignal.timeout(120_000) });
```

If the event stream drops before the turn settles, the call rejects with
`CoderChatError` (`kind: "stream_closed"`, retryable) rather than returning a
truncated result as if the turn had finished.

## Cleanup

`archive()` soft‑hides the chat (it stays in listings as `archived: true`; there
is no hard delete yet). To make cleanup ride scope exit instead of a `finally` you
have to remember, the agent is an **async disposable**:

```ts
await using agent = new CoderAgent({
/* … */
});
const { text } = await agent.generate({ prompt: "…" });
// agent.archive() runs automatically when the scope exits (best‑effort).
```

In a request handler that returns before a fire‑and‑forget `archive()` settles, the
archive can be abandoned — `await using` (or an awaited `archive()` in `finally`)
avoids accumulating live chats.

## Handling errors

All errors extend `CoderAgentError`. Two carry structured detail you can branch on:

- **`CoderApiError`** — an HTTP request failed. Fields: `status`, `method`, `path`, `detail`.
- **`CoderChatError`** — a turn ended in an error, timed out, or lost its stream. Fields: `kind`, `retryable`, `statusCode`, `provider`.

```ts
import { CoderApiError, CoderChatError } from "@coder/ai-sdk-agent";

try {
await agent.generate({ prompt: "…" });
} catch (err) {
if (err instanceof CoderChatError && err.retryable) {
// transient (timeout, stream_closed, an upstream 5xx) — back off and retry
} else if (err instanceof CoderApiError && err.status === 429) {
// rate limited
} else {
throw err;
}
}
```

`maxRetries` defaults to `0`: this agent owns server‑side chat state, so an
SDK‑level retry could duplicate a turn. Prefer catching `retryable` errors and
retrying the whole step deliberately.

## Structured output

`CoderAgent` does **not** constrain output to a JSON schema — chatd has no
server‑side `response_format`, so a `responseFormat` / `experimental_output`
request emits a warning and is best‑effort at most. For reliable
schema‑constrained output, use **[`@coder/ai-sdk-provider`](../provider)** with
`generateObject` / `Output.object` (requires AI Gateway enabled on the
deployment). Use the Agent for the steps that need server‑side tools; use the
provider for pure text‑in / JSON‑out steps.

## Workspaces & quota

A `CoderAgent` is one server‑side chat, and — depending on its configuration and
the deployment — a chat may provision a **Coder workspace** to run its tools. A
deployment caps how many workspaces an account may run at once, so **N agents
running concurrently can need N free workspace slots.** Past the cap, a turn can
sit unscheduled and never settle. This is the most important operational fact when
running many agents at once:

- Keep your own concurrency below the deployment's workspace limit.
- Set `requestTimeoutMs` so an unschedulable turn fails loudly instead of hanging.
- `archive()` / `await using` each agent so finished chats stop holding resources.
- For steps that don't need server‑side tools, prefer the provider — it never touches a workspace.

## Configuration

`CoderAgentSettings`:

| field | description |
| --------------------------------- | ----------------------------------------------------------------------------- |
| `client` \| (`baseUrl` + `token`) | connection (one or the other) |
| `organizationId` | org UUID that owns the chat (required) |
| `model` | model hint: UUID, `provider:model`, model id, or display‑name substring |
| `instructions` | system prompt |
| `tools` | AI SDK `ToolSet` (client‑executed) |
| `workspaceId` | bind the chat to a Coder workspace (enables workspace‑scoped tools) |
| `workspaceFiles` | adapter enabling `uploadToWorkspace()` (write files to the workspace FS) |
| `mcpServerIds` | server‑side MCP servers to enable |
| `planMode` | enable plan mode (`"plan"`) |
| `stopWhen` | AI SDK stop condition(s); default `stepCountIs(64)` |
| `maxRetries` | default `0` — SDK retries can duplicate server‑side turns; override with care |
| `chatId` | resume an existing chat |
| field | description |
| --------------------------------- | ------------------------------------------------------------------------------------------------ |
| `client` \| (`baseUrl` + `token`) | connection (one or the other) |
| `organizationId` | org UUID that owns the chat (required) |
| `model` | model hint: UUID, `provider:model`, model id, or display‑name substring |
| `instructions` | system prompt |
| `tools` | AI SDK `ToolSet` (client‑executed) |
| `workspaceId` | bind the chat to a Coder workspace (enables workspace‑scoped tools) |
| `workspaceFiles` | adapter enabling `uploadToWorkspace()` (write files to the workspace FS) |
| `mcpServerIds` | server‑side MCP servers to enable |
| `planMode` | enable plan mode (`"plan"`) |
| `stopWhen` | AI SDK stop condition(s); default `stepCountIs(64)` |
| `maxRetries` | default `0` — SDK retries can duplicate server‑side turns; override with care |
| `requestTimeoutMs` | per‑turn time budget (ms); interrupts the run and rejects (`kind: "timeout"`) instead of hanging |
| `chatId` | resume an existing chat |

## How it works

Expand All@@ -224,6 +343,28 @@ CoderAgent (implements ai.Agent)
- Streaming text is emitted from `message_part` deltas; fast turns that only produce a full
`message` snapshot are diffed against an emitted‑length cursor — so neither double‑counts.

## Durable workflows (Vercel Workflow, step functions, …)

`CoderAgent` talks to Coder over its own REST + WebSocket client, so it can't ride
a `fetch`‑shim durability layer — each turn must run **inside** a durable step. A
few rules keep it well‑behaved across replays:

- **One turn per step.** Create the agent, run a single `generate()` (not
`stream()`, so the checkpointed value is the finished result), return.
- **Don't persist the instance across steps.** Persist `agent.chatId` (a string)
and resume with `new CoderAgent({ …, chatId })` in the next step. Never persist
or log the token — read it from the environment in each step.
- **Clean up in the step.** `await using` the agent (or `await agent.archive()` in
a `finally`) so a step that returns early doesn't abandon the chat.
- **Bound each step.** Set `requestTimeoutMs` so a wedged turn fails the step (and
lets the workflow retry) instead of hanging the whole run.
- **Mind concurrency vs. workspaces.** Keep fan‑out width under the deployment's
workspace cap — see [Workspaces & quota](#workspaces--quota).
- **Use the provider for pure steps.** Steps that don't need server‑side tools
(plan / extract / synthesize) are cheaper and natively structured through
[`@coder/ai-sdk-provider`](../provider) + `generateObject` — no chat, no
workspace, no cleanup.

## Testing

```bash
Expand Down
53 changes: 53 additions & 0 deletions packages/agent/src/agent/coder-agent.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@ import {
type CoderChatClientOptions,
type UploadedChatFile,
} from "../coder/client.js";
import type { ChatModelConfig } from "../coder/types.js";
import { CoderAgentError } from "../errors.js";
import type { FileContent } from "../files.js";
import { CoderLanguageModel } from "../model/language-model.js";
Expand DownExpand Up@@ -100,6 +101,18 @@ export interface CoderAgentSettings<TOOLS extends ToolSet = {}> {
* SDK retries could duplicate a turn. Override with care.
*/
maxRetries?: number;
/**
* Per-segment time budget in milliseconds, applied to each model round-trip
* (chat creation / message / tool-result submission plus the server run until
* it settles). If a segment runs longer (e.g. the server is wedged or a
* workspace can't be scheduled), the run is interrupted server-side and the
* call rejects with a retryable {@link CoderChatError} (`kind: "timeout"`)
* instead of hanging. A multi-step `generate()` driving client tools makes
* several segments, so this bounds each one — to cap total wall-clock for the
* whole call, pass `abortSignal: AbortSignal.timeout(ms)`. Unset or
* non-positive means no limit.
*/
requestTimeoutMs?: number;
}

/**
Expand DownExpand Up@@ -162,6 +175,7 @@ export class CoderAgent<TOOLS extends ToolSet = {}> implements Agent<never, TOOL
mcpServerIds: settings.mcpServerIds,
planMode: settings.planMode,
chatId: settings.chatId,
requestTimeoutMs: settings.requestTimeoutMs,
});

this.#inner = new ToolLoopAgent<never, TOOLS, never>({
Expand DownExpand Up@@ -207,6 +221,15 @@ export class CoderAgent<TOOLS extends ToolSet = {}> implements Agent<never, TOOL

// --- session helpers ------------------------------------------------------

/**
* List the model configs available on the deployment. Use this to discover
* valid values for the `model` hint instead of guessing ids — match on
* `id`, `provider`/`model`, or `display_name`.
*/
listModels(signal?: AbortSignal): Promise<ChatModelConfig[]> {
return this.#client.listModelConfigs(signal);
}

/** Start a fresh chatd chat on the next turn. */
resetSession(): void {
this.#model.resetSession();
Expand All@@ -224,6 +247,36 @@ export class CoderAgent<TOOLS extends ToolSet = {}> implements Agent<never, TOOL
if (id) await this.#client.archiveChat(id);
}

/**
* Clean up the chat when the agent leaves an `await using` scope, so cleanup
* rides scope exit instead of a separate call you have to remember in a
* `finally`. Interrupts any in-flight server run, then archives the chat.
* Best-effort: disposal errors are swallowed so they can't mask the scope's
* own error.
*
* @example
* ```ts
* await using agent = new CoderAgent({ ... });
* const { text } = await agent.generate({ prompt: "…" });
* // agent.interrupt() + agent.archive() run automatically here.
* ```
*/
async [Symbol.asyncDispose](): Promise<void> {
// archive() only soft-hides the chat; it does not stop a generation. If the
// scope exits mid-turn (e.g. generate() threw, or an early return), interrupt
// first so chatd stops generating and releases the workspace, then archive.
try {
await this.interrupt();
} catch {
/* best-effort cleanup */
}
try {
await this.archive();
} catch {
/* best-effort cleanup */
}
}

// --- files ----------------------------------------------------------------

/**
Expand Down
Loading