mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
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:
parent
0a52f7bf63
commit
abfb3f1f03
38 changed files with 417 additions and 1865 deletions
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal 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
147
AGENTS.md
Normal 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.
|
||||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' }>;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
36
bun.lock
36
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import type { ModelMessage } from 'ai';
|
||||
|
||||
export interface ContextStrategy {
|
||||
trim(messages: ModelMessage[], maxTokens: number): ModelMessage[];
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
64
scripts/ensure-agent-native.cjs
Normal file
64
scripts/ensure-agent-native.cjs
Normal 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');
|
||||
}
|
||||
Loading…
Reference in a new issue