@dex-ai/sdk
v0.1.35
Published
Agent SDK: interfaces + createAgent() + generate loop. One package.
Readme
@dex-ai/sdk
An agent SDK for TypeScript + Bun. One package: interfaces, Agent.create(), and the generate loop.
Layout
The SDK lives in its own repo (dex-ai-sdk). Provider implementations live in a sibling monorepo (dex-ai-providers):
workplace/
├── dex-ai-sdk/ (this repo — @dex-ai/sdk)
│ ├── src/ # types + define* helpers + Agent.create + generate loop
│ ├── examples/ # 8 runnable .ts examples
│ └── docs/ # SVG diagrams
└── dex-ai-providers/ (separate repo — providers monorepo)
└── providers/
└── openai/ # @dex-ai/openai — OpenAI Chat Completions@dex-ai/openai is referenced from examples/package.json via file:../../dex-ai-providers/providers/openai. Clone both side by side for local dev.
Usage
import { Agent, Tool } from '@dex-ai/sdk';
import { openai } from '@dex-ai/openai';
import { z } from 'zod';
const getWeather = Tool.define({
name: 'get_weather',
description: 'Look up current weather.',
parameters: z.object({ city: z.string() }),
async execute({ city }) {
return { type: 'json', value: { city, tempC: 18 } };
},
});
const agent = await Agent.create({
name: 'demo',
provider: openai({ modelId: 'gpt-4.1' }),
extensions: [{ name: 'tools', tools: [getWeather] }],
});
// Non-streaming:
const r = await agent.generate({
input: [{ role: 'user', content: [{ type: 'text', text: 'Weather in Paris?' }] }],
}).result;
// Streaming:
const h = agent.generate({ input: [/* ... */] });
for await (const part of h) {
if (part.type === 'text-delta') process.stdout.write(part.delta);
if (part.type === 'message-committed') saveToDb(part.message);
}
const result = await h.result;Design decisions
One package, two shapes
@dex-ai/sdk ships both the types (so third-party providers and extensions can depend on only the type surface via import type) and the runtime (Agent.create + the loop). There is no separate types-only package.
Content model (flat, ai-sdk aligned)
type Content =
| { type: 'text'; text }
| { type: 'image'; image: string|Uint8Array|URL; mediaType? }
| { type: 'file'; data: string|Uint8Array|URL; mediaType; name? }
| { type: 'reasoning'; text }
| { type: 'tool-call'; toolCallId; toolName; input }
| { type: 'tool-result'; toolCallId; toolName; output: ToolOutput };
type ToolOutput =
| { type: 'content'; value: ContentPayload[] } // text / image / file
| { type: 'json'; value: unknown }
| { type: 'text'; value: string }
| { type: 'error-text'; value: string }
| { type: 'error-json'; value: unknown };Two contexts, two lifetimes
AgentContext is persistent — created by Agent.create(), owns messages: Message[], dies with agent.dispose(). GenerateContext is fresh per generate() call, owns the in-flight assistant content: Content[], lives across all iterations of one generate.
One generate() method
agent.generate(opts) returns an AgentStream: async-iterable AND has a .result: Promise<GenerateResult>. Same run drives both.
Approval via onToolCall
No dedicated approval surface. An approver extension implements onToolCall: return a ToolResult (with error-text) to reject, a rewritten ToolCall to modify, or void to pass through. Rejections flow back to the LLM as data.
Schema-agnostic
Tool.parameters takes any Standard Schema implementation — zod, valibot, arktype. The provider converts to JSON Schema.
Core Loop Architecture
The generate loop is event-driven. Extensions subscribe to events via ext.on['event-name']. The loop orchestrates model calls, tool dispatch, and context injection purely through events.
Agent Creation (Agent.create)
- Collect extensions from options
- Run
ext.init(actx)on each extension (registration order) - Set up tool result cache (if configured)
- Assemble system prompt into a single system message:
opts.systemPrompt(base identity/instructions)- Skills from extensions: evaluate
skill.when(), renderskill.content, build catalog session-startevent: extensions yieldAsyncIterable<Content>for system prompt
- Reorder messages: system first, then conversation history
- Append seed messages
Generate Loop Phases
Each agent.generate(opts) call runs:
Phase A: Input Processing
| Step | Action | Event |
|------|--------|-------|
| A1 | Extensions augment input messages | generate-input |
| A2 | Commit input to actx.messages | message-committed (stream) |
| A3 | Collect ephemeral turn context | generate-start → AsyncIterable<Content> |
| A4 | Build ephemeral context message (step 0 only, never persisted) | — |
Phase B: Iteration Loop (one model call + tool execution)
| Step | Action | Event |
|------|--------|-------|
| B1 | Build ModelRequest (messages + tools + params) | — |
| B2 | Inject ephemeral context (step 0 only) | — |
| B3 | Extensions transform the request (reducer) | model-start → ModelRequest \| void |
| B4 | Compute cache breakpoints (Anthropic-style) | — |
| B5 | Iteration begins | message-start |
| B6 | Stream from model — consume model.stream(req) | text-delta, reasoning-delta, tool-call-delta, tool-call, message-stop, finish |
| B7 | Commit assistant message | message-committed (stream) |
| B8 | Assistant output finalized | message-stop |
| B9 | Update actx.tokenCount | — |
| B10 | Model call complete | model-stop |
Phase C: Tool Dispatch (per tool call)
| Step | Action | Event |
|------|--------|-------|
| C1 | Extensions intercept/modify/short-circuit | tool-start → ToolCall \| ToolResult \| void |
| C2 | Validate input against tool schema | — |
| C3 | Execute tool (raced with timeout + abort) | tool-execute-start (stream) |
| C4 | Extensions transform result (reducer) | tool-stop → ToolResult \| void |
| C5 | Commit tool-result message | tool-execute-finish + message-committed (stream) |
Phase D: Continue or Stop
shouldContinue = finishReason == "tool-calls"
&& pendingToolCalls > 0
&& step + 1 < maxStepsIf continuing → increment step, go back to Phase B.
Phase E: Finalization
| Step | Action | Event |
|------|--------|-------|
| E1 | Turn complete | generate-stop (always fires, even on error) |
| E2 | Build GenerateResult | — |
| E3 | Close stream | generate-finish (stream) |
Event Hook Summary
┌─ Session Lifecycle ─────────────────────────────────────────────┐
│ session-start → yield Content for system prompt │
│ session-stop → cleanup on agent.dispose() │
└─────────────────────────────────────────────────────────────────┘
┌─ Per generate() call ───────────────────────────────────────────┐
│ generate-input → augment input messages │
│ generate-start → yield ephemeral turn context │
│ │
│ ┌─ Per iteration (model call) ──────────────────────────────┐ │
│ │ model-start → transform ModelRequest (reducer) │ │
│ │ message-start → observe │ │
│ │ text-delta → observe streaming text │ │
│ │ reasoning-delta → observe reasoning tokens │ │
│ │ tool-call-delta → observe tool JSON streaming │ │
│ │ message-stop → observe committed message │ │
│ │ model-stop → observe │ │
│ │ │ │
│ │ ┌─ Per tool call ─────────────────────────────────────┐ │ │
│ │ │ tool-start → intercept / modify / short-circuit │ │ │
│ │ │ tool-stop → transform result (reducer) │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ generate-stop → finalize (always fires) │
└─────────────────────────────────────────────────────────────────┘
┌─ Cross-cutting ─────────────────────────────────────────────────┐
│ error → observe any error + ErrorSource context │
└─────────────────────────────────────────────────────────────────┘Extension Interaction Patterns
| Pattern | Events | Description |
|---------|--------|-------------|
| Reducer | model-start, tool-start, tool-stop | Return transformed value or void (no-op) |
| Observer | text-delta, message-stop, generate-stop | Observe but don't modify |
| Context injection | session-start, generate-start, generate-input | Yield content for system prompt or turn context |
| Short-circuit | tool-start | Return a ToolResult to skip tool execution |
| Ephemeral context | generate-start | Content injected into step 0 request but never persisted |
Stream Parts
The AgentStream merges provider parts with loop-synthesized parts:
- Provider:
response-start,text-delta,reasoning-delta,tool-call-delta,tool-call,message-stop,finish,response-stop,error,abort - Loop:
generate-start,generate-finish,iteration-start,iteration-finish,tool-execute-start,tool-execute-finish,message-committed,extension-info
Message Integrity (Messages class)
The conversation history is protected by the Messages class — a guarded container that prevents extensions from corrupting the message sequence.
The problem
Extensions previously cast actx.messages as Message[] to bypass the ReadonlyArray type and directly mutate the array (splice, push, etc.). This caused multiple corruption bugs where tool-calls had no matching tool-results, or role alternation was violated.
The solution
actx.messages is now backed by a Proxy that traps all direct mutations at runtime:
// These all throw at runtime — even with `as any` casts:
actx.messages.push(msg); // ❌ "Direct .push() is not allowed"
actx.messages.splice(0, 1); // ❌ "Direct .splice() is not allowed"
actx.messages[0] = msg; // ❌ "Direct index assignment is not allowed"
actx.messages.length = 0; // ❌ "Direct array mutation is not allowed"Extensions MUST use actx.messageStore for controlled mutations:
const store = actx.messageStore;
// Append (validates after)
store.append(message);
// Splice with validation
store.splice(start, deleteCount, ...replacements);
// Replace a range
store.replaceRange(start, end, newMessages);
// Replace single message
store.replace(index, newMessage);
// Batch mode — defer validation for multi-step mutations
store.batch();
store.splice(0, 10, ...compressed);
store.append(summary);
store.commit(); // validates here, throws if invalid
// Or use transaction() for auto-commit:
store.transaction(() => {
store.splice(warmStart, warmCount, ...summarized);
});Invariants enforced
Every mutation (or commit() in batch mode) validates:
- System messages at front — contiguous prefix only
- First non-system is
user— conversation must start with a user turn - Role alternation —
user→assistant→tool* →user(tool only after assistant) - Tool-call/tool-result pairing — every
tool-callin an assistant message must have a matchingtool-result - No orphaned tool-results — every
tool-resultmust reference a precedingtool-call
Violations throw with a detailed error listing the indices and what went wrong.
Error Handling
reportError(err, source)— structured logging + emitsextension-infoerror part + callsext.on['error']on all extensions- Orphaned tool-call repair — if the loop throws mid-tool-dispatch, synthesizes error
tool-resultmessages so history stays valid generate-stopalways fires — even in catch path, so session persistence can flush
Diagrams
| File | Shows |
|------|-------|
| docs/architecture.svg | Top-level layers: App → Agent → {Provider, Extensions → Tools}. |
| docs/packages.svg | Package graph: @dex-ai/sdk, @dex-ai/openai, examples, deps. |
| docs/interfaces.svg | Every exported interface on one canvas. |
| docs/contexts.svg | AgentContext vs GenerateContext — lifetimes, mutation, integrity. |
| docs/core-loop-sequence.svg | Sequence diagram of one generate() call with hook fire points. |
| docs/extension-interface.svg | All hooks grouped by lifetime and kind. |
| docs/stream-parts.svg | AgentStreamPart timeline: provider + loop-synthesized parts. |
Dev
bun install
bun run typecheck
bun run examples/01-non-streaming.ts