refactor(agent): migrate to @zseven-w/agent-native, update dependencies and clean up code

- Replace @zseven-w/agent with @zseven-w/agent-native across the project.
- Update package.json and bun.lock to reflect new dependencies.
- Refactor chat.ts and agent-sessions.ts to utilize new API from agent-native.
- Clean up imports and ensure compatibility with new structure.
- Remove deprecated agent package and associated tests.
This commit is contained in:
Kayshen-X 2026-04-04 11:57:21 +08:00
parent 0a52f7bf63
commit abfb3f1f03
38 changed files with 417 additions and 1865 deletions

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "packages/agent-native"]
path = packages/agent-native
url = https://github.com/ZSeven-W/agent.git

147
AGENTS.md Normal file
View file

@ -0,0 +1,147 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
Detailed module docs are in `packages/AGENTS.md`, `apps/web/AGENTS.md`, `apps/desktop/AGENTS.md`, and `apps/cli/AGENTS.md` — loaded automatically when working in those directories.
## Commands
- **Dev server:** `bun --bun run dev` (runs on port 3000)
- **Build:** `bun --bun run build`
- **Preview production build:** `bun --bun run preview`
- **Run all tests:** `bun --bun run test` (Vitest)
- **Run a single test:** `bun --bun vitest run path/to/test.ts`
- **Type check:** `npx tsc --noEmit`
- **Install dependencies:** `bun install`
- **Bump version:** `bun run bump <version>` (syncs all package.json files)
- **Electron dev:** `bun run electron:dev` (starts Vite + Electron together)
- **Electron compile:** `bun run electron:compile` (esbuild electron/ to out/desktop/)
- **Electron build:** `bun run electron:build` (full web build + compile + electron-builder package)
- **CLI compile:** `bun run cli:compile` (esbuild CLI to apps/cli/dist/)
- **CLI dev:** `bun run cli:dev` (run CLI from source via Bun)
- **Publish beta:** `bun run publish:beta [N]` (publish all npm packages with beta tag)
## Architecture
OpenPencil is an open-source vector design tool (alternative to Pencil.dev) with a Design-as-Code philosophy. Organized as a **Bun monorepo** with workspaces:
```text
openpencil/
├── apps/
│ ├── web/ TanStack Start full-stack React app (Vite + Nitro)
│ ├── desktop/ Electron desktop app (macOS, Windows, Linux)
│ └── cli/ CLI tool — control the design tool from the terminal
├── packages/
│ ├── pen-types/ Type definitions for PenDocument model
│ ├── pen-core/ Document tree ops, layout engine, variables, boolean ops, clone utilities
│ ├── pen-codegen/ Multi-platform code generators
│ ├── pen-figma/ Figma .fig file parser and converter
│ ├── pen-renderer/ Standalone CanvasKit/Skia renderer
│ ├── pen-sdk/ Umbrella SDK (re-exports all packages)
│ ├── pen-ai-skills/ AI prompt skill engine (phase-driven prompt loading + design memory)
│ └── agent/ Domain-agnostic AI agent SDK (Vercel AI SDK, multi-provider, agent teams)
├── scripts/ Build and publish scripts
└── .githooks/ Pre-commit version sync from branch name
```
**Key technologies:** React 19, CanvasKit/Skia WASM (canvas engine), Paper.js (boolean path operations), Zustand v5 (state management), TanStack Router (file-based routing), Tailwind CSS v4, shadcn/ui (UI primitives), Vite 7, Nitro (server), Electron 35 (desktop), Vercel AI SDK v6 (agent framework), i18next (15 locales), TypeScript (strict mode).
### Data Flow
```text
React Components (Toolbar, LayerPanel, PropertyPanel)
│ Zustand hooks
┌─────────────────┐ ┌───────────────────┐
│ canvas-store │ │ document-store │ ← single source of truth
│ (UI state: │ │ (PenDocument) │
│ tool/selection │ │ CRUD / tree ops │
│ /viewport) │ │ │
└────────┬────────┘ └────────┬──────────┘
│ │
▼ ▼
CanvasKit/Skia canvas-sync-lock
(GPU-accelerated (prevents circular sync)
WASM renderer)
```
- **document-store** is the single source of truth. CanvasKit only renders.
- User edits on canvas → SkiaEngine events → update document-store
- User edits in panels → update document-store → SkiaEngine `syncFromDocument()` re-renders
- `canvas-sync-lock.ts` prevents circular updates when canvas events write to the store
### Multi-Page Architecture
```text
PenDocument
├── pages?: PenPage[] (id, name, children)
└── children: PenNode[] (default/single-page fallback)
```
### Design Variables Architecture
- **`$variable` references are preserved** in the document store (e.g. `$color-1` in fill color)
- `resolveNodeForCanvas()` resolves `$refs` on-the-fly before CanvasKit rendering
- Code generators output `var(--name)` for `$ref` values
- Multiple theme axes supported (e.g. Theme-1 with Light/Dark, Theme-2 with Compact/Comfortable)
### MCP Layered Design Workflow
External LLMs (Codex, Codex, Gemini CLI, etc.) can generate designs via MCP:
- **Single-shot**: `batch_design` or `insert_node` — one call
- **Layered**: `design_skeleton``design_content` × N → `design_refine` — phased generation with focused context
- **Segmented prompts**: `get_design_prompt(section=...)` loads focused subsets (schema, layout, roles, icons, etc.)
### Path Aliases
`@/*` maps to `./src/*` (configured in `apps/web/tsconfig.json` and `apps/web/vite.config.ts`).
### Styling
Tailwind CSS v4 imported via `apps/web/src/styles.css`. UI primitives from shadcn/ui. Icons from `lucide-react`.
### CLI (`apps/cli/`)
The `op` command-line tool controls the desktop app or web server from the terminal. Arguments that accept JSON or DSL support three input methods: inline string, `@filepath` (read from file), or `-` (read from stdin).
- **App control:** `op start [--desktop|--web]`, `op stop`, `op status`
- **Design:** `op design <dsl|@file|->` — batch design DSL operations
- **Document:** `op open`, `op save`, `op get`, `op selection`
- **Nodes:** `op insert`, `op update`, `op delete`, `op move`, `op copy`, `op replace`
- **Export:** `op export <react|html|vue|svelte|flutter|swiftui|compose|rn|css>`
- **Cross-platform:** macOS, Windows (NSIS/portable), Linux (AppImage/deb/snap/flatpak)
### CI / CD
- **`.github/workflows/ci.yml`** — Push/PR on `main` and `v*` branches: type check, tests, web build
- **`.github/workflows/build-electron.yml`** — Tag push (`v*`) or manual: builds Electron for all platforms, creates draft GitHub Release
- **`.github/workflows/publish-cli.yml`** — Tag push (`v*`) or manual: publishes all `@zseven-w/*` npm packages in topological order
- **`.github/workflows/docker.yml`** — Docker image build and push
### Version Sync
- **Pre-commit hook** (`.githooks/pre-commit`): extracts version from branch name (e.g. `v0.5.0``0.5.0`) and syncs to all `package.json` files
- **Manual bump:** `bun run bump <version>` to set a specific version across all workspaces
- Requires `git config core.hooksPath .githooks` (one-time setup per clone)
## Code Style
- Single files must not exceed 800 lines. Split into smaller modules when they grow beyond this limit.
- One component per file, each with a single responsibility.
- `.ts` and `.tsx` files use kebab-case naming, e.g. `canvas-store.ts`, `use-keyboard-shortcuts.ts`.
- UI components must use shadcn/ui design tokens (`bg-card`, `text-foreground`, `border-border`, etc.). No hardcoded Tailwind colors like `gray-*`, `blue-*`.
- Toolbar button active state uses `isActive` conditional className (`bg-primary text-primary-foreground`), not Radix Toggle's `data-[state=on]:` selector (has twMerge conflicts).
## Git Commit Convention
Use [Conventional Commits](https://www.conventionalcommits.org/) format: `<type>(<scope>): <subject>`
**Types:** `feat`, `fix`, `refactor`, `perf`, `style`, `docs`, `test`, `chore`
**Scopes:** `editor`, `canvas`, `panels`, `history`, `ai`, `codegen`, `store`, `types`, `variables`, `figma`, `mcp`, `electron`, `renderer`, `sdk`, `cli`, `agent`, `i18n`
**Rules:** Subject in English, lowercase start, no period, imperative mood. Body is optional; explain **why** not what. One commit per change.
## License
MIT License. See [LICENSE](./LICENSE) for details.

View file

@ -4,7 +4,7 @@
"private": true,
"type": "module",
"dependencies": {
"@zseven-w/agent": "workspace:*",
"@zseven-w/agent-native": "workspace:*",
"@zseven-w/pen-ai-skills": "workspace:*",
"@zseven-w/pen-codegen": "workspace:*",
"@zseven-w/pen-core": "workspace:*",

View file

@ -992,7 +992,7 @@ function streamViaCopilot(body: ChatBody, model?: string) {
/**
* Stream via builtin provider direct API key, no CLI tool needed.
* Uses Vercel AI SDK's streamText with Anthropic or OpenAI-compatible providers.
* Uses Zig NAPI addon (agent-native) with Anthropic or OpenAI-compatible providers.
*/
function streamViaBuiltin(body: ChatBody) {
const stream = new ReadableStream({
@ -1009,43 +1009,70 @@ function streamViaBuiltin(body: ChatBody) {
}, KEEPALIVE_INTERVAL_MS);
try {
const { streamText } = await import('@zseven-w/agent');
const {
createAnthropicProvider,
createOpenAICompatProvider,
createQueryEngine,
seedMessages,
submitMessage,
nextEvent,
destroyIterator,
destroyQueryEngine,
destroyProvider,
} = await import('@zseven-w/agent-native');
const apiKey = body.builtinApiKey;
const model = body.model?.trim();
if (!apiKey || !model) throw new Error('Builtin provider requires apiKey and model');
const { createAnthropicProvider, createOpenAICompatProvider } =
await import('@zseven-w/agent');
const provider =
const builtinProvider =
body.builtinType === 'anthropic'
? createAnthropicProvider({ apiKey, model, baseURL: body.builtinBaseURL })
: createOpenAICompatProvider({ apiKey, model, baseURL: body.builtinBaseURL });
const llmModel = provider.model;
? createAnthropicProvider(apiKey, model, body.builtinBaseURL)
: createOpenAICompatProvider(apiKey, body.builtinBaseURL!, model);
const messages = body.messages.map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
}));
const response = streamText({
model: llmModel,
system: body.system,
messages,
// Pure streaming — no tools, maxTurns=1 prevents agentic looping
const builtinEngine = createQueryEngine({
provider: builtinProvider,
systemPrompt: body.system,
maxTurns: 1,
cwd: process.cwd(),
});
for await (const part of response.fullStream) {
if (part.type === 'text-delta' && part.text) {
// Seed prior conversation history for multi-turn context
const priorMsgs = body.messages.slice(0, -1).filter(
(m: any) =>
(m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string',
);
if (priorMsgs.length > 0) {
seedMessages(builtinEngine, JSON.stringify(priorMsgs));
}
const lastMsg = body.messages[body.messages.length - 1]?.content ?? '';
const builtinIter = await submitMessage(builtinEngine, lastMsg);
try {
let raw: string | null;
while ((raw = await nextEvent(builtinIter)) !== null) {
const evt = JSON.parse(raw);
clearInterval(pingTimer);
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'text', content: part.text })}\n\n`),
);
} else if (part.type === 'reasoning-delta' && (part as any).text) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: 'thinking', content: (part as any).text })}\n\n`,
),
);
if (evt.type === 'text_delta' && evt.text) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: 'text', content: evt.text })}\n\n`,
),
);
} else if (evt.type === 'thinking' && evt.text) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: 'thinking', content: evt.text })}\n\n`,
),
);
}
}
} finally {
destroyIterator(builtinIter);
destroyQueryEngine(builtinEngine);
destroyProvider(builtinProvider);
}
} catch (error) {
const content = error instanceof Error ? error.message : 'Unknown error';

View file

@ -1,25 +1,61 @@
import type { Agent } from '@zseven-w/agent';
import type {
QueryEngineHandle,
IteratorHandle,
ProviderHandle,
ToolRegistryHandle,
} from '@zseven-w/agent-native';
import {
abortEngine,
destroyIterator,
destroyQueryEngine,
destroyToolRegistry,
destroyProvider,
} from '@zseven-w/agent-native';
export interface AgentSession {
agent: Agent;
abortController: AbortController;
engine?: QueryEngineHandle;
iter?: IteratorHandle;
provider: ProviderHandle;
tools?: ToolRegistryHandle;
createdAt: number;
lastActivity: number;
}
export const agentSessions = new Map<string, AgentSession>();
/** Idempotent cleanup — nullifies handles after destroying to prevent double-free. */
export function cleanup(session: AgentSession): void {
if (session.iter) {
destroyIterator(session.iter);
session.iter = undefined;
}
if (session.engine) {
destroyQueryEngine(session.engine);
session.engine = undefined;
}
if (session.tools) {
destroyToolRegistry(session.tools);
session.tools = undefined;
}
if (session.provider) {
destroyProvider(session.provider);
(session as any).provider = undefined;
}
}
/** Abort a session — makes pending nextEvent resolve null. */
export function abortSession(session: AgentSession): void {
if (session.engine) abortEngine(session.engine);
}
// Cleanup stale sessions every 60s (5-minute TTL from last activity)
setInterval(() => {
try {
const now = Date.now();
for (const [id, session] of agentSessions) {
if (now - session.lastActivity > 5 * 60 * 1000) {
try {
session.abortController.abort();
} catch {
/* ignore */
}
if (now - session.lastActivity > 5 * 60_000) {
abortSession(session);
cleanup(session);
agentSessions.delete(id);
}
}

View file

@ -13,7 +13,7 @@ import {
Trash2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { AuthLevel } from '@zseven-w/agent';
import type { AuthLevel } from '@/types/agent';
export interface ToolCallBlockData {
id: string;

View file

@ -1,4 +1,4 @@
import type { AgentEvent, ToolResult, AuthLevel } from '@zseven-w/agent';
import type { AgentEvent, ToolResult, AuthLevel } from '@/types/agent';
import type { PenNode } from '@/types/pen';
type ToolCallEvent = Extract<AgentEvent, { type: 'tool_call' }>;

View file

@ -1,6 +1,11 @@
import { z } from 'zod';
import { createToolRegistry } from '@zseven-w/agent';
import type { AuthLevel } from '@zseven-w/agent';
import type { AuthLevel } from '@/types/agent';
export interface ToolDef {
name: string;
description: string;
level: AuthLevel;
parameters: Record<string, unknown>;
}
const TOOL_AUTH_MAP: Record<string, AuthLevel> = {
// read
@ -40,70 +45,79 @@ const TOOL_AUTH_MAP: Record<string, AuthLevel> = {
remove_page: 'delete',
};
/**
* Create a tool registry pre-loaded with MVP design tools.
* Tool execute functions are NOT provided they run on the client via ToolExecutor.
*/
export function createDesignToolRegistry() {
const registry = createToolRegistry();
// MVP tools (Phase 1: 6 tools)
registry.register({
name: 'batch_get',
description: 'Get nodes by IDs or search patterns from the document tree',
level: TOOL_AUTH_MAP.batch_get,
schema: z.object({
ids: z.array(z.string()).optional().describe('Node IDs to retrieve'),
patterns: z.array(z.string()).optional().describe('Search patterns to match'),
}),
});
registry.register({
name: 'snapshot_layout',
description:
'Get a compact layout snapshot of the current page showing node positions and sizes',
level: TOOL_AUTH_MAP.snapshot_layout,
schema: z.object({
pageId: z.string().optional(),
}),
});
// Design creation — delegates to the full internal pipeline (orchestrator + sub-agents)
registry.register({
name: 'generate_design',
description:
'Generate a complete design on the canvas. Pass a natural language description. The pipeline handles layout, styling, icons, and rendering. Always use this for creating designs.',
level: TOOL_AUTH_MAP.generate_design,
schema: z.object({
prompt: z
.string()
.describe(
'Natural language description of the design, e.g. "a modern mobile login screen with email, password, login button, and social login"',
),
}),
});
// Modification tools — for editing existing designs
registry.register({
name: 'update_node',
description: 'Update properties of an existing node by ID',
level: TOOL_AUTH_MAP.update_node,
schema: z.object({
id: z.string().describe('Node ID to update'),
data: z.record(z.unknown()).describe('Properties to update'),
}),
});
registry.register({
name: 'delete_node',
description: 'Delete a node from the document by ID',
level: TOOL_AUTH_MAP.delete_node,
schema: z.object({
id: z.string().describe('Node ID to delete'),
}),
});
return registry;
export function getDesignToolDefs(): ToolDef[] {
return [
{
name: 'batch_get',
description: 'Get nodes by IDs or search patterns from the document tree',
level: TOOL_AUTH_MAP.batch_get,
parameters: {
type: 'object',
properties: {
ids: { type: 'array', items: { type: 'string' }, description: 'Node IDs to retrieve' },
patterns: {
type: 'array',
items: { type: 'string' },
description: 'Search patterns to match',
},
},
},
},
{
name: 'snapshot_layout',
description:
'Get a compact layout snapshot of the current page showing node positions and sizes',
level: TOOL_AUTH_MAP.snapshot_layout,
parameters: {
type: 'object',
properties: {
pageId: { type: 'string' },
},
},
},
{
name: 'generate_design',
description:
'Generate a complete design on the canvas. Pass a natural language description. The pipeline handles layout, styling, icons, and rendering. Always use this for creating designs.',
level: TOOL_AUTH_MAP.generate_design,
parameters: {
type: 'object',
properties: {
prompt: {
type: 'string',
description:
'Natural language description of the design, e.g. "a modern mobile login screen with email, password, login button, and social login"',
},
},
required: ['prompt'],
},
},
{
name: 'update_node',
description: 'Update properties of an existing node by ID',
level: TOOL_AUTH_MAP.update_node,
parameters: {
type: 'object',
properties: {
id: { type: 'string', description: 'Node ID to update' },
data: { type: 'object', description: 'Properties to update' },
},
required: ['id', 'data'],
},
},
{
name: 'delete_node',
description: 'Delete a node from the document by ID',
level: TOOL_AUTH_MAP.delete_node,
parameters: {
type: 'object',
properties: {
id: { type: 'string', description: 'Node ID to delete' },
},
required: ['id'],
},
},
];
}
export { TOOL_AUTH_MAP };

View file

@ -1,4 +1,13 @@
import type { AuthLevel } from '../tools/types';
// Agent event types — extracted from @zseven-w/agent for zero-runtime-dependency usage.
// These types define the SSE protocol between server and client.
export type AuthLevel = 'read' | 'create' | 'modify' | 'delete' | 'orchestrate';
export interface ToolResult {
success: boolean;
data?: unknown;
error?: string;
}
export type AgentEvent =
| { type: 'thinking'; content: string; source?: string }
@ -15,7 +24,7 @@ export type AgentEvent =
type: 'tool_result';
id: string;
name: string;
result: { success: boolean; data?: unknown; error?: string };
result: ToolResult;
source?: string;
}
| { type: 'turn'; turn: number; maxTurns: number; source?: string }

View file

@ -93,7 +93,7 @@
"name": "@zseven-w/web",
"version": "0.6.0",
"dependencies": {
"@zseven-w/agent": "workspace:*",
"@zseven-w/agent-native": "workspace:*",
"@zseven-w/pen-ai-skills": "workspace:*",
"@zseven-w/pen-codegen": "workspace:*",
"@zseven-w/pen-core": "workspace:*",
@ -105,15 +105,9 @@
"zod": "^3.24",
},
},
"packages/agent": {
"name": "@zseven-w/agent",
"version": "0.6.0",
"dependencies": {
"@ai-sdk/anthropic": "^3.0",
"@ai-sdk/openai": "^3.0",
"ai": "^6.0",
"zod": "^3.24",
},
"packages/agent-native/napi": {
"name": "@zseven-w/agent-native",
"version": "0.1.0",
},
"packages/pen-ai-skills": {
"name": "@zseven-w/pen-ai-skills",
@ -273,16 +267,6 @@
"@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "https://registry.npmmirror.com/@ai-sdk/anthropic/-/anthropic-3.0.64.tgz", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.80", "https://registry.npmmirror.com/@ai-sdk/gateway/-/gateway-3.0.80.tgz", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uM7kpZB5l977lW7+2X1+klBUxIZQ78+1a9jHlaHFEzcOcmmslTl3sdP0QqfuuBcO0YBM2gwOiqVdp8i4TRQYcw=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "https://registry.npmmirror.com/@ai-sdk/openai/-/openai-3.0.48.tgz", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-3.0.8.tgz", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "https://registry.npmmirror.com/@ai-sdk/provider-utils/-/provider-utils-4.0.21.tgz", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.81", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-CBeebgibBEN/DWOQGZN67vhuTG55RbI1hlsFSSoZ4uA/Io3lw04eHTE2ISCmdbqyJaefYTt6GKZei1nP0TQMNw=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.77.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TivlT6nfidz3sOyMF72T2x5AkmHrpT7JgL2e/0HNdh7b24v7JC8cR+rCY/42jA68xIsjmiGQ5IKMsH9feEKh3A=="],
@ -529,8 +513,6 @@
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.6", "", {}, "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "https://registry.npmmirror.com/@opentelemetry/api/-/api-1.9.0.tgz", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.42.0", "", { "os": "android", "cpu": "arm" }, "sha512-dsqPTYsozeokRjlrt/b4E7Pj0z3eS3Eg74TWQuuKbjY4VttBmA88rB7d50Xrd+TZ986qdXCNeZRPEzZHAe+jow=="],
@ -773,8 +755,6 @@
"@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
@ -919,8 +899,6 @@
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "https://registry.npmmirror.com/@vercel/oidc/-/oidc-3.1.0.tgz", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
@ -941,7 +919,7 @@
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
"@zseven-w/agent": ["@zseven-w/agent@workspace:packages/agent"],
"@zseven-w/agent-native": ["@zseven-w/agent-native@workspace:packages/agent-native/napi"],
"@zseven-w/desktop": ["@zseven-w/desktop@workspace:apps/desktop"],
@ -977,8 +955,6 @@
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ai": ["ai@6.0.138", "https://registry.npmmirror.com/ai/-/ai-6.0.138.tgz", { "dependencies": { "@ai-sdk/gateway": "3.0.80", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-49OfPe0f5uxJ6jUdA5BBXjIinP6+ZdYfAtpF2aEH64GA5wPcxH2rf/TBUQQ0bbamBz/D+TLMV18xilZqOC+zaA=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
@ -1477,8 +1453,6 @@
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema": ["json-schema@0.4.0", "https://registry.npmmirror.com/json-schema/-/json-schema-0.4.0.tgz", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],

View file

@ -12,6 +12,7 @@
},
"workspaces": [
"packages/*",
"packages/agent-native/napi",
"apps/*"
],
"type": "module",
@ -40,7 +41,8 @@
"format": "oxfmt .",
"format:check": "oxfmt --check .",
"generate": "bun -e \"import { compileSkills } from './packages/pen-ai-skills/vite-plugin-skills'; compileSkills('./packages/pen-ai-skills')\"",
"postinstall": "(bun scripts/patch-srvx-bun.ts && bun run generate) || true"
"agent:build": "cd packages/agent-native && zig build napi -Doptimize=ReleaseFast",
"postinstall": "(bun scripts/patch-srvx-bun.ts && bun run generate && node scripts/ensure-agent-native.cjs) || true"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.47",

View file

@ -1,120 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { createAgent } from '../src/agent-loop';
import { createToolRegistry } from '../src/tools/tool-registry';
import type { AgentProvider } from '../src/providers/types';
import type { AgentEvent } from '../src/streaming/types';
import type { FallbackStrategy } from '../src/tools/types';
const mockProvider: AgentProvider = {
id: 'mock',
maxContextTokens: 100_000,
supportsThinking: false,
model: {} as any,
};
const defaultFallback: FallbackStrategy = {
systemSuffix: '\n\nFallback: output JSON in a code fence.',
parseResponse: (text: string) => JSON.parse(text),
};
const allowAll = async () => 'allow' as const;
describe('createAgent', () => {
it('creates an agent with required config', () => {
const tools = createToolRegistry();
const agent = createAgent({
provider: mockProvider,
tools,
systemPrompt: 'You are helpful.',
fallbackStrategy: defaultFallback,
beforeToolExecute: allowAll,
maxTurns: 5,
});
expect(agent).toHaveProperty('run');
expect(agent).toHaveProperty('resolveToolResult');
});
it('agent.run returns an async iterable', async () => {
const tools = createToolRegistry();
const agent = createAgent({
provider: mockProvider,
tools,
systemPrompt: 'You are helpful.',
fallbackStrategy: defaultFallback,
beforeToolExecute: allowAll,
maxTurns: 5,
});
const stream = agent.run([{ role: 'user', content: 'hi' }]);
expect(stream[Symbol.asyncIterator]).toBeDefined();
});
it('resolveToolResult buffers result for unknown tool call id (pre-resolution)', () => {
const tools = createToolRegistry();
const agent = createAgent({
provider: mockProvider,
tools,
systemPrompt: 'You are helpful.',
fallbackStrategy: defaultFallback,
beforeToolExecute: allowAll,
});
// Pre-resolution: calling resolveToolResult before waitForToolResult
// should NOT throw — the result is buffered for later consumption.
expect(() => agent.resolveToolResult('nonexistent', { success: true })).not.toThrow();
});
it('yields abort event when signal is already aborted', async () => {
const tools = createToolRegistry();
const controller = new AbortController();
controller.abort();
const agent = createAgent({
provider: mockProvider,
tools,
systemPrompt: 'You are helpful.',
fallbackStrategy: defaultFallback,
beforeToolExecute: allowAll,
abortSignal: controller.signal,
});
const events: AgentEvent[] = [];
for await (const event of agent.run([{ role: 'user', content: 'hi' }])) {
events.push(event);
}
// Abort check runs before the turn yield, so only abort is emitted
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ type: 'abort' });
});
it('uses default maxTurns of 20', () => {
const tools = createToolRegistry();
const agent = createAgent({
provider: mockProvider,
tools,
systemPrompt: 'You are helpful.',
fallbackStrategy: defaultFallback,
beforeToolExecute: allowAll,
});
// Verify it was created without error — default maxTurns=20 is internal
expect(agent).toBeDefined();
});
it('beforeToolExecute deny returns permission error without throwing', async () => {
// This test verifies the contract: deny produces a ToolResult, not an exception.
// We can't easily run the full agent loop without mocking streamText,
// so we test the interface shape — full integration is covered by the agent team tests.
const tools = createToolRegistry();
const denyAll = vi.fn(async () => 'deny' as const);
const agent = createAgent({
provider: mockProvider,
tools,
systemPrompt: 'You are helpful.',
fallbackStrategy: defaultFallback,
beforeToolExecute: denyAll,
});
expect(agent).toBeDefined();
// The deny function is called during tool execution in the agent loop,
// which requires a real LLM response. The unit test validates it's wired in.
expect(denyAll).not.toHaveBeenCalled(); // not called until run()
});
});

View file

@ -1,80 +0,0 @@
import { describe, it, expect } from 'vitest';
import { createTeam } from '../src/agent-team';
import { createToolRegistry } from '../src/tools/tool-registry';
import type { AgentProvider } from '../src/providers/types';
import type { FallbackStrategy } from '../src/tools/types';
const mockProvider: AgentProvider = {
id: 'mock',
maxContextTokens: 100_000,
supportsThinking: false,
model: {} as any,
};
const defaultFallback: FallbackStrategy = {
systemSuffix: '\n\nFallback: output JSON in a code fence.',
parseResponse: (text: string) => JSON.parse(text),
};
const allowAll = async () => 'allow' as const;
describe('createTeam', () => {
it('creates a team with lead and members', () => {
const team = createTeam({
lead: {
provider: mockProvider,
tools: createToolRegistry(),
systemPrompt: 'You are a lead.',
},
members: [
{
id: 'worker',
provider: mockProvider,
tools: createToolRegistry(),
systemPrompt: 'You are a worker.',
},
],
fallbackStrategy: defaultFallback,
beforeToolExecute: allowAll,
});
expect(team).toHaveProperty('run');
expect(team).toHaveProperty('abort');
});
it('accepts different providers for lead and members', () => {
const otherProvider: AgentProvider = { ...mockProvider, id: 'other' };
const team = createTeam({
lead: { provider: mockProvider, tools: createToolRegistry(), systemPrompt: 'Lead' },
members: [
{
id: 'member1',
provider: otherProvider,
tools: createToolRegistry(),
systemPrompt: 'Member',
},
],
fallbackStrategy: defaultFallback,
beforeToolExecute: allowAll,
});
expect(team).toBeDefined();
});
it('does not mutate the caller tools registry', () => {
const tools = createToolRegistry();
const initialCount = tools.list().length;
createTeam({
lead: { provider: mockProvider, tools, systemPrompt: 'Lead' },
members: [
{
id: 'worker',
provider: mockProvider,
tools: createToolRegistry(),
systemPrompt: 'Worker',
},
],
fallbackStrategy: defaultFallback,
beforeToolExecute: allowAll,
});
expect(tools.list().length).toBe(initialCount);
});
});

View file

@ -1,133 +0,0 @@
import { describe, it, expect } from 'vitest';
import { createSlidingWindowStrategy } from '../../src/context/sliding-window';
describe('createSlidingWindowStrategy', () => {
const strategy = createSlidingWindowStrategy({ maxTurns: 3 });
it('keeps messages within maxTurns', () => {
const messages = Array.from({ length: 10 }, (_, i) => ({
role: (i % 2 === 0 ? 'user' : 'assistant') as const,
content: `Message ${i}`,
}));
const trimmed = strategy.trim(messages, 100_000);
expect(trimmed.length).toBeLessThanOrEqual(6); // 3 turns = 6 messages
});
it('preserves system messages', () => {
const messages = [
{ role: 'system' as const, content: 'You are helpful' },
{ role: 'user' as const, content: 'old message' },
{ role: 'assistant' as const, content: 'old reply' },
{ role: 'user' as const, content: 'new message' },
{ role: 'assistant' as const, content: 'new reply' },
];
const strategy1 = createSlidingWindowStrategy({ maxTurns: 1 });
const trimmed = strategy1.trim(messages, 100_000);
expect(trimmed[0]).toEqual({ role: 'system', content: 'You are helpful' });
// 1 logical turn = last user or assistant message group
// The last turn starts at "new message" (user) → keeps user + assistant = 2 + system = 3
// OR starts at "new reply" (assistant) → keeps just 1 + system = 2
// Our impl: turnStarts = [user@0, assistant@1, user@2, assistant@3] → keep last 1 → from index 3 → assistant only
// So: system + assistant("new reply") = 2
expect(trimmed.length).toBeLessThanOrEqual(3);
});
it('returns all messages if within limits', () => {
const messages = [
{ role: 'user' as const, content: 'hi' },
{ role: 'assistant' as const, content: 'hello' },
];
const trimmed = strategy.trim(messages, 100_000);
expect(trimmed).toEqual(messages);
});
it('never splits assistant+tool groups', () => {
// Simulate: user, assistant(tool-call), tool, tool, assistant(text), user, assistant(tool-call), tool
const messages = [
{ role: 'user' as const, content: 'first request' },
{ role: 'assistant' as const, content: 'calling tools' },
{
role: 'tool' as const,
content: [
{
type: 'tool-result' as const,
toolCallId: 't1',
toolName: 'foo',
output: { type: 'text' as const, value: 'r1' },
},
],
},
{
role: 'tool' as const,
content: [
{
type: 'tool-result' as const,
toolCallId: 't2',
toolName: 'bar',
output: { type: 'text' as const, value: 'r2' },
},
],
},
{ role: 'assistant' as const, content: 'done with first' },
{ role: 'user' as const, content: 'second request' },
{ role: 'assistant' as const, content: 'calling more tools' },
{
role: 'tool' as const,
content: [
{
type: 'tool-result' as const,
toolCallId: 't3',
toolName: 'baz',
output: { type: 'text' as const, value: 'r3' },
},
],
},
];
const strategy2 = createSlidingWindowStrategy({ maxTurns: 2 });
const trimmed = strategy2.trim(messages as any, 100_000);
// First message should be user or assistant, NEVER tool
expect(trimmed[0].role).not.toBe('tool');
// Should keep last 2 logical turns
expect(trimmed.length).toBeGreaterThanOrEqual(3);
});
it('first kept message is never a tool message', () => {
const messages = [
{ role: 'user' as const, content: 'q1' },
{ role: 'assistant' as const, content: 'a1' },
{
role: 'tool' as const,
content: [
{
type: 'tool-result' as const,
toolCallId: 't1',
toolName: 'x',
output: { type: 'text' as const, value: '' },
},
],
},
{ role: 'user' as const, content: 'q2' },
{ role: 'assistant' as const, content: 'a2' },
{
role: 'tool' as const,
content: [
{
type: 'tool-result' as const,
toolCallId: 't2',
toolName: 'y',
output: { type: 'text' as const, value: '' },
},
],
},
{ role: 'user' as const, content: 'q3' },
{ role: 'assistant' as const, content: 'a3' },
];
const strategy1 = createSlidingWindowStrategy({ maxTurns: 1 });
const trimmed = strategy1.trim(messages as any, 100_000);
// Should never start with tool
expect(trimmed[0].role).not.toBe('tool');
});
});

View file

@ -1,24 +0,0 @@
import { describe, it, expect } from 'vitest';
import { createDelegateTool } from '../src/tools/delegate';
describe('createDelegateTool', () => {
it('creates a tool with orchestrate level', () => {
const tool = createDelegateTool(['designer', 'reviewer']);
expect(tool.name).toBe('delegate');
expect(tool.level).toBe('orchestrate');
});
it('schema validates member names', async () => {
const tool = createDelegateTool(['designer', 'reviewer']);
const schema = tool.schema as { validate?: (v: unknown) => unknown };
const result = await schema.validate?.({ member: 'designer', task: 'do thing' });
expect((result as { success: boolean }).success).toBe(true);
});
it('schema rejects unknown members', async () => {
const tool = createDelegateTool(['designer', 'reviewer']);
const schema = tool.schema as { validate?: (v: unknown) => unknown };
const result = await schema.validate?.({ member: 'unknown', task: 'do thing' });
expect((result as { success: boolean }).success).toBe(false);
});
});

View file

@ -1,24 +0,0 @@
import { describe, it, expect } from 'vitest';
import { createAnthropicProvider } from '../../src/providers/anthropic';
describe('createAnthropicProvider', () => {
it('creates a provider with correct metadata', () => {
const provider = createAnthropicProvider({
apiKey: 'test-key',
model: 'claude-sonnet-4-20250514',
});
expect(provider.id).toBe('anthropic');
expect(provider.supportsThinking).toBe(true);
expect(provider.maxContextTokens).toBe(200_000);
expect(provider.model).toBeDefined();
});
it('allows custom maxContextTokens', () => {
const provider = createAnthropicProvider({
apiKey: 'test-key',
model: 'claude-sonnet-4-20250514',
maxContextTokens: 100_000,
});
expect(provider.maxContextTokens).toBe(100_000);
});
});

View file

@ -1,28 +0,0 @@
import { describe, it, expect } from 'vitest';
import { createOllamaProvider } from '../../src/providers/ollama';
describe('createOllamaProvider', () => {
it('creates a provider with correct metadata', () => {
const provider = createOllamaProvider({ model: 'llama3' });
expect(provider.id).toBe('ollama');
expect(provider.supportsThinking).toBe(false);
expect(provider.maxContextTokens).toBe(128_000);
expect(provider.model).toBeDefined();
});
it('allows custom baseURL', () => {
const provider = createOllamaProvider({
model: 'codellama',
baseURL: 'http://192.168.1.100:11434/v1',
});
expect(provider.id).toBe('ollama');
});
it('allows custom maxContextTokens', () => {
const provider = createOllamaProvider({
model: 'llama3',
maxContextTokens: 32_000,
});
expect(provider.maxContextTokens).toBe(32_000);
});
});

View file

@ -1,24 +0,0 @@
import { describe, it, expect } from 'vitest';
import { createOpenAICompatProvider } from '../../src/providers/openai-compat';
describe('createOpenAICompatProvider', () => {
it('creates a provider with correct metadata', () => {
const provider = createOpenAICompatProvider({
apiKey: 'test-key',
model: 'gpt-4o',
});
expect(provider.id).toBe('openai-compat');
expect(provider.supportsThinking).toBe(false);
expect(provider.maxContextTokens).toBe(128_000);
expect(provider.model).toBeDefined();
});
it('supports custom baseURL', () => {
const provider = createOpenAICompatProvider({
apiKey: 'test-key',
model: 'deepseek-chat',
baseURL: 'https://api.deepseek.com/v1',
});
expect(provider.id).toBe('openai-compat');
});
});

View file

@ -1,46 +0,0 @@
import { describe, it, expect } from 'vitest';
import { decodeAgentEvent } from '../../src/streaming/sse-decoder';
describe('decodeAgentEvent', () => {
it('decodes a text event', () => {
const raw = 'event: text\ndata: {"content":"hello"}\n\n';
const event = decodeAgentEvent(raw);
expect(event).toEqual({ type: 'text', content: 'hello' });
});
it('decodes a tool_call event', () => {
const raw =
'event: tool_call\ndata: {"id":"tc_1","name":"read_file","args":{"path":"/foo"},"level":"read"}\n\n';
const event = decodeAgentEvent(raw);
expect(event).toEqual({
type: 'tool_call',
id: 'tc_1',
name: 'read_file',
args: { path: '/foo' },
level: 'read',
});
});
it('returns null for malformed input', () => {
expect(decodeAgentEvent('garbage')).toBeNull();
expect(decodeAgentEvent('')).toBeNull();
});
it('decodes a text event with source', () => {
const raw = 'event: text\ndata: {"content":"hello","source":"designer"}';
const event = decodeAgentEvent(raw);
expect(event).toEqual({ type: 'text', content: 'hello', source: 'designer' });
});
it('decodes a member_start event', () => {
const raw = 'event: member_start\ndata: {"memberId":"designer","task":"Build UI"}';
const event = decodeAgentEvent(raw);
expect(event).toEqual({ type: 'member_start', memberId: 'designer', task: 'Build UI' });
});
it('decodes a member_end event', () => {
const raw = 'event: member_end\ndata: {"memberId":"designer","result":"Done"}';
const event = decodeAgentEvent(raw);
expect(event).toEqual({ type: 'member_end', memberId: 'designer', result: 'Done' });
});
});

View file

@ -1,54 +0,0 @@
import { describe, it, expect } from 'vitest';
import { encodeAgentEvent } from '../../src/streaming/sse-encoder';
import type { AgentEvent } from '../../src/streaming/types';
describe('encodeAgentEvent', () => {
it('encodes a text event', () => {
const event: AgentEvent = { type: 'text', content: 'hello' };
const encoded = encodeAgentEvent(event);
expect(encoded).toBe('event: text\ndata: {"content":"hello"}\n\n');
});
it('encodes a tool_call event', () => {
const event: AgentEvent = {
type: 'tool_call',
id: 'tc_1',
name: 'read_file',
args: { path: '/foo' },
level: 'read',
};
const encoded = encodeAgentEvent(event);
expect(encoded).toContain('event: tool_call');
expect(encoded).toContain('"id":"tc_1"');
});
it('encodes a done event', () => {
const event: AgentEvent = { type: 'done', totalTurns: 3 };
const encoded = encodeAgentEvent(event);
expect(encoded).toBe('event: done\ndata: {"totalTurns":3}\n\n');
});
it('encodes a text event with source', () => {
const event: AgentEvent = { type: 'text', content: 'hello', source: 'lead' };
const encoded = encodeAgentEvent(event);
expect(encoded).toBe('event: text\ndata: {"content":"hello","source":"lead"}\n\n');
});
it('encodes a member_start event', () => {
const event: AgentEvent = {
type: 'member_start',
memberId: 'designer',
task: 'Create landing page',
};
const encoded = encodeAgentEvent(event);
expect(encoded).toBe(
'event: member_start\ndata: {"memberId":"designer","task":"Create landing page"}\n\n',
);
});
it('encodes a member_end event', () => {
const event: AgentEvent = { type: 'member_end', memberId: 'designer', result: 'Done' };
const encoded = encodeAgentEvent(event);
expect(encoded).toBe('event: member_end\ndata: {"memberId":"designer","result":"Done"}\n\n');
});
});

View file

@ -1,173 +0,0 @@
import { describe, it, expect } from 'vitest';
import { jsonSchema } from 'ai';
import { createToolRegistry } from '../src/tools/tool-registry';
/** Simple JSON schema for tests — avoids zod ESM resolution issue in vitest. */
const emptySchema = jsonSchema({ type: 'object', properties: {} });
const pathSchema = jsonSchema({
type: 'object',
properties: { path: { type: 'string' } },
required: ['path'],
});
const insertSchema = jsonSchema({
type: 'object',
properties: { parent: { type: 'string' }, data: { type: 'object' } },
required: ['parent', 'data'],
});
describe('createToolRegistry', () => {
it('registers and retrieves a tool', () => {
const registry = createToolRegistry();
registry.register({
name: 'read_file',
description: 'Read a file',
schema: pathSchema,
level: 'read',
});
expect(registry.get('read_file')).toBeDefined();
expect(registry.get('read_file')!.level).toBe('read');
});
it('lists all registered tools', () => {
const registry = createToolRegistry();
registry.register({ name: 'tool_a', description: 'A', schema: emptySchema, level: 'read' });
registry.register({ name: 'tool_b', description: 'B', schema: emptySchema, level: 'modify' });
expect(registry.list()).toHaveLength(2);
});
it('converts to Vercel AI SDK format', () => {
const registry = createToolRegistry();
registry.register({
name: 'insert_node',
description: 'Insert a node',
schema: insertSchema,
level: 'create',
});
const sdkTools = registry.toAISDKFormat();
expect(sdkTools).toHaveProperty('insert_node');
expect(sdkTools.insert_node).toHaveProperty('description', 'Insert a node');
expect(sdkTools.insert_node).toHaveProperty('inputSchema');
});
it('throws on duplicate registration', () => {
const registry = createToolRegistry();
const tool = { name: 'dup', description: 'D', schema: emptySchema, level: 'read' as const };
registry.register(tool);
expect(() => registry.register(tool)).toThrow();
});
// --- New lifecycle methods ---
describe('unregister', () => {
it('removes a registered tool and returns true', () => {
const registry = createToolRegistry();
registry.register({ name: 'rm_me', description: 'X', schema: emptySchema, level: 'read' });
expect(registry.unregister('rm_me')).toBe(true);
expect(registry.get('rm_me')).toBeUndefined();
});
it('returns false for a non-existent tool', () => {
const registry = createToolRegistry();
expect(registry.unregister('ghost')).toBe(false);
});
it('allows re-registration after unregister', () => {
const registry = createToolRegistry();
registry.register({ name: 'temp', description: 'T', schema: emptySchema, level: 'read' });
registry.unregister('temp');
// Should not throw on re-register
registry.register({
name: 'temp',
description: 'T2',
schema: emptySchema,
level: 'modify',
});
expect(registry.get('temp')!.level).toBe('modify');
});
});
describe('replace', () => {
it('atomically replaces an existing tool', () => {
const registry = createToolRegistry();
registry.register({ name: 'swap', description: 'Old', schema: emptySchema, level: 'read' });
registry.replace({ name: 'swap', description: 'New', schema: emptySchema, level: 'modify' });
expect(registry.get('swap')!.description).toBe('New');
expect(registry.get('swap')!.level).toBe('modify');
});
it('throws if tool does not exist', () => {
const registry = createToolRegistry();
expect(() =>
registry.replace({
name: 'nope',
description: 'X',
schema: emptySchema,
level: 'read',
}),
).toThrow('not registered');
});
it('does not expose intermediate state (tool count stays the same)', () => {
const registry = createToolRegistry();
registry.register({ name: 't', description: 'V1', schema: emptySchema, level: 'read' });
const countBefore = registry.list().length;
registry.replace({ name: 't', description: 'V2', schema: emptySchema, level: 'read' });
expect(registry.list().length).toBe(countBefore);
});
});
describe('snapshot', () => {
it('returns a copy of all tools', () => {
const registry = createToolRegistry();
registry.register({ name: 'a', description: 'A', schema: emptySchema, level: 'read' });
registry.register({ name: 'b', description: 'B', schema: emptySchema, level: 'modify' });
const snap = registry.snapshot();
expect(snap.size).toBe(2);
expect(snap.get('a')!.description).toBe('A');
});
it('mutations to snapshot do not affect the registry', () => {
const registry = createToolRegistry();
registry.register({ name: 'x', description: 'X', schema: emptySchema, level: 'read' });
const snap = registry.snapshot();
snap.delete('x');
expect(registry.get('x')).toBeDefined();
});
});
describe('getByPlugin', () => {
it('returns tools matching a pluginName', () => {
const registry = createToolRegistry();
registry.register({
name: 'p1_a',
description: 'A',
schema: emptySchema,
level: 'read',
pluginName: 'plugin-one',
});
registry.register({
name: 'p1_b',
description: 'B',
schema: emptySchema,
level: 'modify',
pluginName: 'plugin-one',
});
registry.register({
name: 'p2_a',
description: 'C',
schema: emptySchema,
level: 'read',
pluginName: 'plugin-two',
});
const result = registry.getByPlugin('plugin-one');
expect(result).toHaveLength(2);
expect(result.map((t) => t.name).sort()).toEqual(['p1_a', 'p1_b']);
});
it('returns empty array when no tools match', () => {
const registry = createToolRegistry();
registry.register({ name: 'x', description: 'X', schema: emptySchema, level: 'read' });
expect(registry.getByPlugin('nonexistent')).toHaveLength(0);
});
});
});

View file

@ -1,23 +0,0 @@
{
"name": "@zseven-w/agent",
"version": "0.6.0",
"files": [
"src"
],
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0",
"@ai-sdk/openai": "^3.0",
"ai": "^6.0",
"zod": "^3.24"
}
}

View file

@ -1,435 +0,0 @@
import { streamText } from 'ai';
import type { ModelMessage } from 'ai';
import type { AgentProvider } from './providers/types';
import type { ToolRegistry } from './tools/tool-registry';
import type { ToolResult, ToolCallInfo, FallbackStrategy } from './tools/types';
import type { AgentEvent } from './streaming/types';
import type { ContextStrategy } from './context/types';
import { createSlidingWindowStrategy } from './context/sliding-window';
export interface AgentConfig {
provider: AgentProvider;
tools: ToolRegistry;
systemPrompt: string;
fallbackStrategy: FallbackStrategy;
beforeToolExecute: (call: ToolCallInfo) => Promise<'allow' | 'deny'>;
maxTurns?: number;
maxOutputTokens?: number;
turnTimeout?: number;
contextStrategy?: ContextStrategy;
abortSignal?: AbortSignal;
}
export interface Agent {
run(messages: ModelMessage[]): AsyncGenerator<AgentEvent>;
resolveToolResult(toolCallId: string, result: ToolResult): void;
}
/** Classify API errors into actionable categories for the user. */
function classifyAPIError(err: any): { userMessage: string; retryWithoutTools: boolean } {
const msg = err?.message ?? String(err);
const body = err?.responseBody ?? '';
const status = err?.statusCode ?? err?.status;
// Match on HTTP status codes first (more reliable than regex)
if (status === 429) {
return {
userMessage: 'Rate limited by the API provider. Please wait a moment and try again.',
retryWithoutTools: false,
};
}
if (status === 402 || status === 403) {
if (/credit|funds|billing|afford/i.test(msg + body)) {
return {
userMessage:
'Insufficient credits for this model. Try a smaller/free model or add credits at your provider.',
retryWithoutTools: false,
};
}
}
// Model doesn't support function calling properly
if (
/tool_calls.*required|function.*arguments.*required|invalid.*function.*arguments|does not support.*tool/i.test(
msg + body,
)
) {
return {
userMessage:
'This model does not support function calling properly. The agent will try without tools.',
retryWithoutTools: true,
};
}
// OpenRouter privacy/guardrail restrictions
if (/No endpoints available.*guardrail|data policy/i.test(msg + body)) {
return {
userMessage:
'This model is blocked by your OpenRouter privacy settings. Visit https://openrouter.ai/settings/privacy to configure, or switch to a different model.',
retryWithoutTools: false,
};
}
// Credit/billing issues
if (/more credits|can only afford|insufficient.*funds|billing/i.test(msg + body)) {
return {
userMessage:
'Insufficient credits for this model. Try a smaller/free model or add credits at your provider.',
retryWithoutTools: false,
};
}
// Rate limiting
if (/rate.limit|too many requests|429/i.test(msg + body)) {
return {
userMessage: 'Rate limited by the API provider. Please wait a moment and try again.',
retryWithoutTools: false,
};
}
// Generic 400 — often means the model can't handle our request format
if (err?.statusCode === 400 && /provider returned error/i.test(msg + body)) {
return {
userMessage:
'The model returned an error. It may not support tool calling. Retrying without tools.',
retryWithoutTools: true,
};
}
// Fallback
return { userMessage: msg, retryWithoutTools: false };
}
export function createAgent(config: AgentConfig): Agent {
const {
provider,
tools,
systemPrompt,
fallbackStrategy,
beforeToolExecute,
maxTurns = 20,
maxOutputTokens,
turnTimeout = 60_000,
contextStrategy = createSlidingWindowStrategy({ maxTurns: 50 }),
abortSignal,
} = config;
// Pending tool call resolution map — for tools without execute()
const pending = new Map<
string,
{
resolve: (result: ToolResult) => void;
reject: (error: Error) => void;
}
>();
// Pre-resolved results — handles the case where resolveToolResult is called
// BEFORE waitForToolResult (e.g. agent-team resolves delegate before the
// lead generator advances past its yield to register the pending entry).
const preResolved = new Map<string, ToolResult>();
function resolveToolResult(toolCallId: string, result: ToolResult): void {
const entry = pending.get(toolCallId);
if (entry) {
pending.delete(toolCallId);
entry.resolve(result);
} else {
// Buffer the result — waitForToolResult will pick it up when called later
preResolved.set(toolCallId, result);
}
}
function waitForToolResult(toolCallId: string): Promise<ToolResult> {
// Check for a pre-resolved result (resolveToolResult was called first)
const buffered = preResolved.get(toolCallId);
if (buffered) {
preResolved.delete(toolCallId);
return Promise.resolve(buffered);
}
return new Promise<ToolResult>((resolve, reject) => {
const timeout = setTimeout(() => {
pending.delete(toolCallId);
reject(new Error(`Tool call ${toolCallId} timed out after ${turnTimeout}ms`));
}, turnTimeout);
pending.set(toolCallId, {
resolve: (result) => {
clearTimeout(timeout);
resolve(result);
},
reject: (error) => {
clearTimeout(timeout);
reject(error);
},
});
});
}
async function* run(messages: ModelMessage[]): AsyncGenerator<AgentEvent> {
let turn = 0;
const history = [...messages];
let toolsDisabled = false;
let consecutiveToolFailures = 0;
let lastToolSignature = '';
let consecutiveIdenticalCalls = 0;
const MAX_CONSECUTIVE_TOOL_FAILURES = 3;
const MAX_CONSECUTIVE_IDENTICAL_CALLS = 3;
try {
while (turn < maxTurns) {
if (abortSignal?.aborted) {
yield { type: 'abort' };
return;
}
yield { type: 'turn', turn, maxTurns };
// Apply context strategy before each LLM call
const trimmedMessages = contextStrategy.trim(history, provider.maxContextTokens);
// When tools are disabled (model doesn't support function calling),
// append the domain-specific fallback suffix provided by the consumer.
const effectiveSystem = toolsDisabled
? systemPrompt + fallbackStrategy.systemSuffix
: systemPrompt;
let response: ReturnType<typeof streamText>;
try {
response = streamText({
model: provider.model,
system: effectiveSystem,
messages: trimmedMessages as ModelMessage[],
tools: toolsDisabled ? undefined : tools.toAISDKFormat(),
maxOutputTokens,
abortSignal,
});
} catch (err: any) {
// Synchronous errors from streamText (rare — usually validation)
const { userMessage } = classifyAPIError(err);
if (!toolsDisabled && turn === 0) {
toolsDisabled = true;
yield {
type: 'error',
message: `Tool calling failed: ${userMessage}. Retrying without tools.`,
fatal: false,
};
continue;
}
yield { type: 'error', message: userMessage, fatal: true };
return;
}
// Collect tool calls emitted during this turn
const pendingToolCalls: Array<{
toolCallId: string;
toolName: string;
input: unknown;
}> = [];
let accumulatedText = '';
let streamError: any = null;
try {
for await (const part of response.fullStream) {
switch (part.type) {
case 'text-delta':
if (part.text) {
accumulatedText += part.text;
yield { type: 'text', content: part.text };
}
break;
case 'tool-call':
pendingToolCalls.push({
toolCallId: part.toolCallId,
toolName: part.toolName,
input: part.input ?? {},
});
break;
case 'reasoning-delta':
if (part.text) {
yield { type: 'thinking', content: part.text };
}
break;
case 'error':
streamError = (part as any).error;
break;
}
}
} catch (err: any) {
// API errors during streaming (tool_calls format issues, provider errors, etc.)
const { userMessage } = classifyAPIError(err);
if (!toolsDisabled) {
// Retry without tools — strip tool history and restart from user messages only
toolsDisabled = true;
// Reset history to original user messages (remove tool call/result entries)
history.length = 0;
history.push(...messages);
turn = 0;
yield {
type: 'error',
message: `Tool calling failed: ${userMessage}. Retrying without tools.`,
fatal: false,
};
continue;
}
yield { type: 'error', message: userMessage, fatal: true };
return;
}
// Handle stream-level errors
if (streamError) {
yield { type: 'error', message: String(streamError), fatal: false };
}
// No tool calls means the model is done
if (!pendingToolCalls.length) {
yield { type: 'done', totalTurns: turn + 1 };
return;
}
// Process each tool call: either execute directly or suspend for external resolution
const toolResults: Array<{ id: string; name: string; result: ToolResult }> = [];
for (const toolCall of pendingToolCalls) {
const level = tools.getLevel(toolCall.toolName) ?? 'read';
const callInfo: ToolCallInfo = {
id: toolCall.toolCallId,
name: toolCall.toolName,
args: toolCall.input,
level,
};
yield { type: 'tool_call', ...callInfo };
// Permission hook — consumers can deny tool execution before it happens
const permission = await beforeToolExecute(callInfo);
let toolResult: ToolResult;
if (permission === 'deny') {
toolResult = {
success: false,
error: `Permission denied: tool "${toolCall.toolName}" was blocked by permission policy.`,
};
} else if (tools.hasExecute(toolCall.toolName)) {
try {
const tool = tools.get(toolCall.toolName)!;
const data = await tool.execute!(toolCall.input);
toolResult = { success: true, data };
} catch (err) {
toolResult = {
success: false,
error: err instanceof Error ? err.message : JSON.stringify(err),
};
}
} else {
toolResult = await waitForToolResult(toolCall.toolCallId);
}
toolResults.push({
id: toolCall.toolCallId,
name: toolCall.toolName,
result: toolResult,
});
yield {
type: 'tool_result',
id: toolCall.toolCallId,
name: toolCall.toolName,
result: toolResult,
};
}
// Detect stuck loops: consecutive failures or repeated identical calls.
// Models like GLM-5.1 may call tools with empty args or repeat read calls indefinitely.
const allFailed = toolResults.length > 0 && toolResults.every((tr) => !tr.result.success);
if (allFailed) {
consecutiveToolFailures++;
if (consecutiveToolFailures >= MAX_CONSECUTIVE_TOOL_FAILURES) {
yield {
type: 'error',
message: `Tool calls failed ${consecutiveToolFailures} times in a row. Stopping to avoid infinite loop.`,
fatal: false,
};
yield { type: 'done', totalTurns: turn + 1 };
return;
}
} else {
consecutiveToolFailures = 0;
}
// Detect repeated identical tool calls (e.g. calling snapshot_layout with same args)
const turnSignature = pendingToolCalls
.map((tc) => `${tc.toolName}:${JSON.stringify(tc.input)}`)
.join('|');
if (turnSignature === lastToolSignature) {
consecutiveIdenticalCalls++;
if (consecutiveIdenticalCalls >= MAX_CONSECUTIVE_IDENTICAL_CALLS) {
yield {
type: 'error',
message: `Same tool call repeated ${consecutiveIdenticalCalls + 1} times with identical results. Stopping.`,
fatal: false,
};
yield { type: 'done', totalTurns: turn + 1 };
return;
}
} else {
consecutiveIdenticalCalls = 0;
}
lastToolSignature = turnSignature;
// Use the SDK's own response messages for conversation history.
// This ensures correct format conversion (arguments stringification, etc.)
// instead of manually constructing ModelMessage[] which loses format details.
const resolved = await response.response;
// response.messages contains assistant + auto-executed tool results in a format
// compatible with streamText(). Cast needed: ResponseMessage ≠ ModelMessage at type level.
const responseMessages = resolved.messages as unknown as ModelMessage[];
// The SDK includes assistant message + tool result messages (for tools with execute()).
// For tools WITHOUT execute (client-side execution), we only get the assistant message.
// We need to add tool result messages for those.
history.push(...responseMessages);
// Manually add tool results for client-executed tools (not auto-included by SDK).
// Structure matches the AI SDK's expected tool result format.
for (const tr of toolResults) {
if (!tools.hasExecute(tr.name)) {
history.push({
role: 'tool' as const,
content: [
{
type: 'tool-result' as const,
toolCallId: tr.id,
toolName: tr.name,
output: {
type: 'text' as const,
value: JSON.stringify(tr.result.data ?? tr.result.error ?? ''),
},
},
],
} as unknown as ModelMessage);
}
}
turn++;
}
// Reached max turns — treat as a normal completion, not an error.
// The model may have done useful work even if it didn't finish cleanly.
yield { type: 'done', totalTurns: maxTurns };
} finally {
// Clean up pending tool calls to prevent timeout leaks
for (const [, entry] of pending) {
entry.reject(new Error('Agent loop ended'));
}
pending.clear();
preResolved.clear();
}
}
return { run, resolveToolResult };
}

View file

@ -1,200 +0,0 @@
import type { ModelMessage } from 'ai';
import type { AgentProvider } from './providers/types';
import type { ToolRegistry } from './tools/tool-registry';
import type { AgentEvent } from './streaming/types';
import type { ContextStrategy } from './context/types';
import type { ToolResult, ToolCallInfo, FallbackStrategy } from './tools/types';
import { createAgent } from './agent-loop';
import { createDelegateTool } from './tools/delegate';
import { createToolRegistry } from './tools/tool-registry';
export interface TeamMemberConfig {
id: string;
provider: AgentProvider;
tools: ToolRegistry;
systemPrompt: string;
maxTurns?: number;
turnTimeout?: number;
contextStrategy?: ContextStrategy;
/**
* When a member calls a tool with empty args, inject the delegation task
* into the specified arg field. Map of tool name arg field name.
* Example: `{ 'generate_design': 'prompt' }` if generate_design is called
* with no `prompt`, the delegation task text is injected as the prompt.
*/
taskFallbackArgs?: Record<string, string>;
}
export interface TeamConfig {
lead: {
provider: AgentProvider;
tools: ToolRegistry;
systemPrompt: string;
maxTurns?: number;
turnTimeout?: number;
contextStrategy?: ContextStrategy;
};
members: TeamMemberConfig[];
fallbackStrategy: FallbackStrategy;
beforeToolExecute: (call: ToolCallInfo) => Promise<'allow' | 'deny'>;
}
export interface AgentTeam {
run(messages: ModelMessage[]): AsyncGenerator<AgentEvent>;
abort(): void;
resolveToolResult(toolCallId: string, result: ToolResult): void;
}
/** Build a team-awareness suffix for the lead's system prompt. */
function buildTeamSuffix(members: TeamMemberConfig[]): string {
const lines = members.map((m) => {
const desc = m.systemPrompt.split('\n')[0] || 'Available for sub-tasks';
return `- **${m.id}**: ${desc}`;
});
return `\n\n## Team Members\nYou have team members available via the \`delegate\` tool:\n${lines.join('\n')}\n\nWhen the user's request involves design or specialized work, delegate immediately — do not plan or describe the design yourself. Write a clear task for the member and let them handle it.\nHandle only conversation and simple queries yourself.`;
}
export function createTeam(config: TeamConfig): AgentTeam {
const parentAbort = new AbortController();
const memberIds = config.members.map((m) => m.id);
// Track which agent owns each pending tool call so resolveToolResult routes correctly.
// Without this, member tool calls (e.g. generate_design) would be sent to leadAgent
// which doesn't have them in its pending map, causing "No pending tool call" errors.
const toolCallOwners = new Map<
string,
{ resolveToolResult: (id: string, result: ToolResult) => void }
>();
// Clone tools to avoid mutating the caller's registry
const leadTools = createToolRegistry();
for (const tool of config.lead.tools.list()) {
leadTools.register(tool);
}
leadTools.register(createDelegateTool(memberIds));
const leadAgent = createAgent({
provider: config.lead.provider,
tools: leadTools,
systemPrompt: config.lead.systemPrompt + buildTeamSuffix(config.members),
fallbackStrategy: config.fallbackStrategy,
beforeToolExecute: config.beforeToolExecute,
maxTurns: config.lead.maxTurns ?? 30,
turnTimeout: config.lead.turnTimeout,
contextStrategy: config.lead.contextStrategy,
abortSignal: parentAbort.signal,
});
const memberAgents = new Map(
config.members.map((m) => [
m.id,
{
config: m,
agent: createAgent({
provider: m.provider,
tools: m.tools,
systemPrompt: m.systemPrompt,
fallbackStrategy: config.fallbackStrategy,
beforeToolExecute: config.beforeToolExecute,
maxTurns: m.maxTurns ?? 20,
turnTimeout: m.turnTimeout,
contextStrategy: m.contextStrategy,
abortSignal: parentAbort.signal,
}),
},
]),
);
async function* run(messages: ModelMessage[]): AsyncGenerator<AgentEvent> {
for await (const event of leadAgent.run(messages)) {
if (event.type === 'tool_call' && event.name === 'delegate') {
const { member, task, context } = event.args as {
member: string;
task: string;
context?: string;
};
const memberEntry = memberAgents.get(member);
if (!memberEntry) {
leadAgent.resolveToolResult(event.id, {
success: false,
error: `Unknown team member: ${member}`,
});
continue;
}
// Yield the delegate tool_call with lead source
yield { ...event, source: 'lead' };
// Signal member start
yield { type: 'member_start', memberId: member, task };
const memberMessages: ModelMessage[] = [
{
role: 'user',
content: typeof context === 'string' ? `${task}\n\nContext: ${context}` : task,
},
];
let memberResult = '';
for await (const memberEvent of memberEntry.agent.run(memberMessages)) {
// Register member tool calls for correct resolveToolResult routing
if (memberEvent.type === 'tool_call') {
toolCallOwners.set(memberEvent.id, memberEntry.agent);
}
// Patch empty tool args: some models (e.g. GLM-5.1) call tools with empty {}
// despite receiving the task in the conversation. Inject the delegation task
// into the configured arg field so the tool executor can proceed.
let patchedEvent = memberEvent;
if (memberEvent.type === 'tool_call' && memberEntry.config.taskFallbackArgs) {
const fallbackField = memberEntry.config.taskFallbackArgs[memberEvent.name];
if (fallbackField) {
const args = memberEvent.args as Record<string, unknown> | undefined;
if (!args?.[fallbackField]) {
patchedEvent = { ...memberEvent, args: { ...args, [fallbackField]: task } };
}
}
}
// Tag all member events with source
yield { ...patchedEvent, source: member } as AgentEvent;
if (memberEvent.type === 'text') {
memberResult += memberEvent.content;
}
}
// Signal member end
yield { type: 'member_end', memberId: member, result: memberResult || 'Task completed.' };
leadAgent.resolveToolResult(event.id, {
success: true,
data: memberResult || 'Task completed.',
});
continue;
}
// Register lead tool calls for correct resolveToolResult routing
if (event.type === 'tool_call') {
toolCallOwners.set(event.id, leadAgent);
}
// Tag lead events with source
yield { ...event, source: 'lead' } as AgentEvent;
}
}
return {
run,
abort: () => parentAbort.abort(),
resolveToolResult: (id, result) => {
const owner = toolCallOwners.get(id);
if (owner) {
toolCallOwners.delete(id);
owner.resolveToolResult(id, result);
} else {
// Fallback to lead for tool calls not tracked (e.g. delegate)
leadAgent.resolveToolResult(id, result);
}
},
};
}

View file

@ -1,41 +0,0 @@
import type { ModelMessage } from 'ai';
import type { ContextStrategy } from './types';
export interface SlidingWindowOptions {
maxTurns: number;
}
export function createSlidingWindowStrategy(options: SlidingWindowOptions): ContextStrategy {
return {
// maxTokens is part of the ContextStrategy interface but unused by this turn-based strategy.
// A future token-counting strategy would use it for accurate budget trimming.
trim(messages: ModelMessage[], _maxTokens: number): ModelMessage[] {
const systemMessages = messages.filter((m) => m.role === 'system');
const nonSystem = messages.filter((m) => m.role !== 'system');
// Count "logical turns" — a turn starts at a user or assistant message
// and includes all following tool messages that belong to it.
// We must never split an assistant+tool group.
const turnStarts: number[] = [];
for (let i = 0; i < nonSystem.length; i++) {
const role = nonSystem[i].role;
if (role === 'user' || role === 'assistant') {
turnStarts.push(i);
}
// 'tool' messages belong to the preceding assistant turn — skip
}
if (turnStarts.length <= options.maxTurns) {
return [...systemMessages, ...nonSystem];
}
// Keep the last N turns (by their start indices)
const keepFrom = turnStarts[turnStarts.length - options.maxTurns];
const kept = nonSystem.slice(keepFrom);
// Ensure the first kept message is user or assistant, never tool
// (should already be the case since we cut at turnStarts)
return [...systemMessages, ...kept];
},
};
}

View file

@ -1,5 +0,0 @@
import type { ModelMessage } from 'ai';
export interface ContextStrategy {
trim(messages: ModelMessage[], maxTokens: number): ModelMessage[];
}

View file

@ -1,37 +0,0 @@
// @zseven-w/agent — Domain-agnostic agent SDK
// Public API will be exported here as modules are implemented
// Tools
export type { AgentTool, AuthLevel, ToolResult, ToolCallInfo, FallbackStrategy } from './tools/types';
export { createToolRegistry } from './tools/tool-registry';
export type { ToolRegistry } from './tools/tool-registry';
// Streaming
export type { AgentEvent, AgentMessage, AgentMessagePart } from './streaming/types';
export { encodeAgentEvent } from './streaming/sse-encoder';
export { decodeAgentEvent } from './streaming/sse-decoder';
// Providers
export type { ProviderConfig, AgentProvider } from './providers/types';
export { createAnthropicProvider } from './providers/anthropic';
export { createOpenAICompatProvider } from './providers/openai-compat';
export { createOllamaProvider } from './providers/ollama';
export type { OllamaProviderConfig } from './providers/ollama';
// Context
export type { ContextStrategy } from './context/types';
export { createSlidingWindowStrategy } from './context/sliding-window';
// Delegate
export { createDelegateTool } from './tools/delegate';
// Agent
export { createAgent } from './agent-loop';
export type { AgentConfig, Agent } from './agent-loop';
// Re-export AI SDK utilities needed by consumers
export { jsonSchema, streamText } from 'ai';
// Team
export { createTeam } from './agent-team';
export type { TeamConfig, TeamMemberConfig, AgentTeam } from './agent-team';

View file

@ -1,17 +0,0 @@
import { createAnthropic } from '@ai-sdk/anthropic';
import type { AgentProvider, ProviderConfig } from './types';
const DEFAULT_MAX_CONTEXT = 200_000;
export function createAnthropicProvider(config: ProviderConfig): AgentProvider {
const anthropic = createAnthropic({
apiKey: config.apiKey,
...(config.baseURL ? { baseURL: config.baseURL } : {}),
});
return {
model: anthropic(config.model),
id: 'anthropic',
maxContextTokens: config.maxContextTokens ?? DEFAULT_MAX_CONTEXT,
supportsThinking: true,
};
}

View file

@ -1,23 +0,0 @@
import { createOpenAI } from '@ai-sdk/openai';
import type { AgentProvider } from './types';
const DEFAULT_MAX_CONTEXT = 128_000;
export interface OllamaProviderConfig {
model: string;
baseURL?: string;
maxContextTokens?: number;
}
export function createOllamaProvider(config: OllamaProviderConfig): AgentProvider {
const ollama = createOpenAI({
apiKey: 'ollama',
baseURL: config.baseURL ?? 'http://localhost:11434/v1',
});
return {
model: ollama(config.model),
id: 'ollama',
maxContextTokens: config.maxContextTokens ?? DEFAULT_MAX_CONTEXT,
supportsThinking: false,
};
}

View file

@ -1,48 +0,0 @@
import { createOpenAI } from '@ai-sdk/openai';
import type { AgentProvider, ProviderConfig } from './types';
const DEFAULT_MAX_CONTEXT = 128_000;
export function createOpenAICompatProvider(config: ProviderConfig): AgentProvider {
const openai = createOpenAI({
apiKey: config.apiKey,
...(config.baseURL ? { baseURL: config.baseURL } : {}),
// Safety net: ensure tool_calls[].function.arguments is always a valid
// JSON string. Some SDK versions or edge cases may produce missing/empty
// arguments which strict APIs (MiniMax, StepFun) reject.
fetch: async (url, options) => {
if (options?.body && typeof options.body === 'string') {
try {
const body = JSON.parse(options.body);
let patched = false;
for (const msg of body.messages ?? []) {
if (!msg.tool_calls) continue;
for (const tc of msg.tool_calls) {
if (!tc.function) continue;
const args = tc.function.arguments;
if (args === undefined || args === null || args === '') {
tc.function.arguments = '{}';
patched = true;
} else if (typeof args !== 'string') {
tc.function.arguments = JSON.stringify(args);
patched = true;
}
}
}
if (patched) {
options = { ...options, body: JSON.stringify(body) };
}
} catch {
/* ignore */
}
}
return globalThis.fetch(url, options);
},
});
return {
model: openai.chat(config.model),
id: 'openai-compat',
maxContextTokens: config.maxContextTokens ?? DEFAULT_MAX_CONTEXT,
supportsThinking: false,
};
}

View file

@ -1,16 +0,0 @@
import type { LanguageModel } from 'ai';
export interface ProviderConfig {
apiKey: string;
model: string;
baseURL?: string;
maxContextTokens?: number;
providerOptions?: Record<string, unknown>;
}
export interface AgentProvider {
model: LanguageModel;
id: string;
maxContextTokens: number;
supportsThinking: boolean;
}

View file

@ -1,14 +0,0 @@
import type { AgentEvent } from './types';
export function decodeAgentEvent(raw: string): AgentEvent | null {
const eventMatch = raw.match(/^event:\s*(\S+)/);
const dataMatch = raw.match(/^data:\s*(.+)$/m);
if (!eventMatch || !dataMatch) return null;
try {
const type = eventMatch[1] as AgentEvent['type'];
const payload = JSON.parse(dataMatch[1]);
return { type, ...payload } as AgentEvent;
} catch {
return null;
}
}

View file

@ -1,6 +0,0 @@
import type { AgentEvent } from './types';
export function encodeAgentEvent(event: AgentEvent): string {
const { type, ...payload } = event;
return `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`;
}

View file

@ -1,44 +0,0 @@
import { jsonSchema } from 'ai';
import type { AgentTool } from './types';
export function createDelegateTool(memberIds: string[]): AgentTool {
return {
name: 'delegate',
description: `Delegate a sub-task to a team member. Available members: ${memberIds.join(', ')}`,
level: 'orchestrate',
schema: jsonSchema(
{
type: 'object',
properties: {
member: { type: 'string', enum: memberIds, description: 'Team member to delegate to' },
task: { type: 'string', description: 'Clear description of the sub-task' },
context: { type: 'string', description: 'Additional context for the member' },
},
required: ['member', 'task'],
additionalProperties: false,
},
{
validate: (value) => {
if (typeof value !== 'object' || value === null) {
return { success: false, error: new Error('Expected an object') };
}
const v = value as Record<string, unknown>;
if (typeof v.member !== 'string' || !memberIds.includes(v.member)) {
return {
success: false,
error: new Error(`member must be one of: ${memberIds.join(', ')}`),
};
}
if (typeof v.task !== 'string' || v.task.length === 0) {
return { success: false, error: new Error('task must be a non-empty string') };
}
if (v.context !== undefined && typeof v.context !== 'string') {
return { success: false, error: new Error('context must be a string if provided') };
}
return { success: true, value: v };
},
},
),
// No execute — handled by agent-team.ts
};
}

View file

@ -1,63 +0,0 @@
import { tool as aiTool } from 'ai';
import type { Tool } from 'ai';
import type { AgentTool, AuthLevel } from './types';
export interface ToolRegistry {
register(tool: AgentTool): void;
unregister(name: string): boolean;
replace(tool: AgentTool): void;
get(name: string): AgentTool | undefined;
getLevel(name: string): AuthLevel | undefined;
list(): AgentTool[];
snapshot(): Map<string, AgentTool>;
getByPlugin(pluginName: string): AgentTool[];
toAISDKFormat(): Record<string, Tool>;
hasExecute(name: string): boolean;
}
export function createToolRegistry(): ToolRegistry {
const tools = new Map<string, AgentTool>();
return {
register(tool) {
if (tools.has(tool.name)) throw new Error(`Tool "${tool.name}" is already registered`);
tools.set(tool.name, tool);
},
unregister(name) {
return tools.delete(name);
},
replace(tool) {
if (!tools.has(tool.name))
throw new Error(`Tool "${tool.name}" is not registered — cannot replace`);
tools.set(tool.name, tool);
},
get(name) {
return tools.get(name);
},
getLevel(name) {
return tools.get(name)?.level;
},
list() {
return Array.from(tools.values());
},
snapshot() {
return new Map(tools);
},
getByPlugin(pluginName) {
return Array.from(tools.values()).filter((t) => t.pluginName === pluginName);
},
toAISDKFormat() {
const result: Record<string, Tool> = {};
for (const [name, t] of tools) {
result[name] = aiTool({
description: t.description,
inputSchema: t.schema,
...(t.execute ? { execute: t.execute } : {}),
});
}
return result;
},
hasExecute(name) {
return typeof tools.get(name)?.execute === 'function';
},
};
}

View file

@ -1,38 +0,0 @@
import type { z } from 'zod';
import type { FlexibleSchema } from 'ai';
export type AuthLevel = 'read' | 'create' | 'modify' | 'delete' | 'orchestrate';
export interface AgentTool<TArgs = any, TResult = any> {
name: string;
description: string;
/** Zod schema or AI SDK jsonSchema() — used as inputSchema for the LLM. */
schema: z.ZodType<TArgs> | FlexibleSchema<TArgs>;
level: AuthLevel;
pluginName?: string;
execute?: (args: TArgs) => Promise<TResult>;
}
export interface ToolResult {
success: boolean;
data?: unknown;
error?: string;
}
export interface ToolCallInfo {
id: string;
name: string;
args: unknown;
level: AuthLevel;
}
export interface FallbackStrategy {
/** Appended to system prompt when tool calling is unavailable. */
systemSuffix: string;
/**
* Parse the model's text response in fallback mode.
* Called by the consumer on accumulated text events, not by the SDK internally.
* Allows the strategy to co-locate parsing logic with the prompt it generates.
*/
parseResponse: (text: string) => unknown;
}

View file

@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}

View file

@ -0,0 +1,64 @@
#!/usr/bin/env node
// Ensures the Zig NAPI addon binary exists.
// Tries: 1) already built, 2) compile from source, 3) download prebuilt.
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const NAPI_DIR = path.join(__dirname, '..', 'packages', 'agent-native', 'napi');
const ZIG_OUT = path.join(__dirname, '..', 'packages', 'agent-native', 'zig-out', 'napi', 'agent_napi.node');
const BUNDLED = path.join(NAPI_DIR, 'agent_napi.node');
// 1. Already exists?
if (fs.existsSync(ZIG_OUT) || fs.existsSync(BUNDLED)) {
console.log('[agent-native] Binary found, skipping build.');
process.exit(0);
}
// Check if submodule is initialized
if (!fs.existsSync(path.join(NAPI_DIR, 'package.json'))) {
console.log('[agent-native] Submodule not initialized, skipping. Run: git submodule update --init');
process.exit(0);
}
// 2. Try Zig build
try {
execSync('zig version', { stdio: 'ignore' });
console.log('[agent-native] Zig found, building from source...');
execSync('zig build napi -Doptimize=ReleaseFast', {
cwd: path.join(__dirname, '..', 'packages', 'agent-native'),
stdio: 'inherit',
});
if (fs.existsSync(ZIG_OUT)) {
console.log('[agent-native] Build successful.');
process.exit(0);
}
} catch {
console.log('[agent-native] Zig not available, trying prebuilt download...');
}
// 3. Download prebuilt
const REPO = 'ZSeven-W/agent';
const platform = process.platform;
const arch = process.arch;
const assetName = `agent_napi-${platform}-${arch}.node`;
try {
const releaseJson = execSync(
`curl -sL https://api.github.com/repos/${REPO}/releases/latest`,
{ encoding: 'utf8' },
);
const release = JSON.parse(releaseJson);
const asset = release.assets?.find((a) => a.name === assetName);
if (!asset) {
console.warn(`[agent-native] No prebuilt binary for ${platform}-${arch}. Build manually with: bun run agent:build`);
process.exit(0);
}
console.log(`[agent-native] Downloading ${asset.browser_download_url}...`);
execSync(`curl -sL -o "${BUNDLED}" "${asset.browser_download_url}"`, { stdio: 'inherit' });
console.log('[agent-native] Download complete.');
} catch (err) {
console.warn(`[agent-native] Could not download prebuilt binary: ${err.message}`);
console.warn('[agent-native] Build manually with: bun run agent:build');
}