@ai-zen/agents-sdk
AI-Zen SDK — shared business logic layer providing a unified Agent runtime for CLI and Desktop. Works out of the box, with preconfigured vendor settings, a default Agent, and a default SubAgent.
Source of Truth
docs/sdk-design-v1.md is the single source of truth for this package's design. All implementations must stay consistent with the document. (Pre-refactor design is kept in docs/sdk-design-v0.md.)
Architecture
CLI ──┐
├── @ai-zen/agents-sdk ──┐
Desktop ──┘ │
LLM API
Module Layering
types ← pure types, zero business dependencies (incl. ToolEnv)
config ← ConfigManager + constants: config.json read/write + directory setup + factory defaults
crud ← capability-entity CRUD (Agent definitions; conversations/drafts are persisted by each consumer)
runtime ← model factory + Agent assembly + task handoff/migration (cancellable via `AbortSignal`; createModel / createAgent / SdkAgent / HandoffService / HandoffGuardPlugin)
scope ← Scope orchestration container + ScopePlugin contract + PermissionEvaluator / disclosure + plugins/ (5 self-contained capability sources: discovery + tools + plugin)
agent-plugins ← Agent plugins (autoMigrate, autoRefreshTools, contextGuard, unknownToolHint)
shared ← content blocks (file → content block transport), entity repository, logging, errors
Dependency direction: agent-plugins → runtime → scope; runtime → scope / crud / shared / types, scope → types / shared. Upper layers depend on lower layers.
Core Concepts
| Entity | Description |
|---|---|
| Scope | Orchestration container: global context (config / cwd / agentsDir) + the three-phase capability pipeline (discover → filter → instantiate) + plugin registry. Explicitly assembled via scope.use(...) |
| ScopePlugin | Capability plugin (the v1 extension point): self-owns one capability source's discovery state, candidate names, and instantiation (discover / candidates / instantiate / dispose). 5 standard plugins ship out of the box, assembled via allInOne() |
| ToolEnv | Tool environment { cwd, config }; injected when the built-in-tools plugin (BuiltinToolsScopePlugin) instantiates tools, serving as the base for relative path resolution and config reads |
| SdkCallbackTool | Abstract base for built-in tools: env constructor injection + subclass call() + resolve() relative path resolution |
| SdkAgent | Extends the Core Agent, carries SDK metadata, supports use() plugin registration |
| AgentPlugin | Plugin interface (onInit, onBeforeSend, onAfterSend, onInnerLoopStart, onInnerLoopEnd, onInnerLoopsStart, onInnerLoopsEnd, onToolCall, onUnknownTool, onSubAgentStart, onSubAgentEnd) |
| Endpoint | API endpoint (baseUrl + apiKey) |
| Model | Model config, bound to an Endpoint |
| SubAgent | An Agent with a function field, callable by other Agents as a tool |
Permission Model
The four dimensions are independent; allow/deny are mutually exclusive, no match means deny, and permission is disclosure (denied items are completely invisible to the LLM).
Agent.permissions
├── tools: { allow: string[] } | { deny: string[] }
├── skills: { allow: string[] } | { deny: string[] }
├── mcps: { allow: string[] } | { deny: string[] }
└── subagents: { allow: string[] } | { deny: string[] }
Consumption
const scope = new Scope({
config,
cwd: "/path/to/workspace", // one working directory per Scope; parallel sessions don't interfere
agentsDir,
}).use(
...allInOne({ skillsPaths, toolsPaths, subAgentsPaths, mcpPaths }),
);
await scope.init();
const agent = await createAgent(scope, "my-agent");
const handoffService = new HandoffService({ handoffDir: "/work/.ai-zen/handoffs", skillDirs });
agent.use(new ContextGuardPlugin({ guardTokens, isSuspended: () => handoffService.busy }));
agent.use(new AutoMigratePlugin({ service: handoffService, handoffTokens }));
agent.use(new AutoRefreshToolsPlugin());
agent.use(new UnknownToolHintPlugin({ scope }));
await agent.init();
await agent.send("Hello");
await scope.dispose(); // disconnect MCP, release plugin resources
Development Status
| Module | Status |
|---|---|
types | ✅ Implemented — core entities, permission model, MCP types complete |
config | ✅ Implemented — ConfigManager + factory defaults + one-shot bootstrap |
crud | ✅ Implemented — capability-entity CRUD for Agents, etc. (conversations/drafts persisted by each consumer) |
scope | ✅ Implemented — Scope + ScopePlugin + 5 standard plugins (builtin / user-tools / skills / mcp / subagents) + allInOne |
runtime | ✅ Implemented — createAgent, MCP connection management, task migration |
agent-plugins | ✅ Implemented — AutoMigratePlugin / AutoRefreshToolsPlugin / ContextGuardPlugin / UnknownToolHintPlugin |
shared | ✅ Implemented — SdkError + injectable Logger |
Built-in Tools
All built-in tools are classes (extending SdkCallbackTool), instantiated by the built-in-tools plugin (BuiltinToolsScopePlugin) with a ToolEnv — one set of instances per Scope, with cwd injected and relative paths resolved against Scope.cwd, never depending on the global process.cwd().
| Tool | Description |
|---|---|
cwd | Get the current working directory |
read | Read a file (optional range for line/column slicing; oversized output warns and suggests batched reads) |
inspect_file | Inspect a file's structural overview (line count, column distribution, etc.); streamed scan, not limited by the 300KB read threshold |
write | Write a file |
exec | Execute a command (supports timeout; oversized stdout+stderr is dumped to stdout.log / stderr.log) |
spawn | Execute a command asynchronously, returns immediately without waiting |
mkdir | Create a directory |
rm | Delete a file or directory |
glob | Scan and find files using glob patterns (oversized output dumped to result.json) |
ls | List directory contents (oversized output dumped to result.json) |
exist | Check whether a file or directory exists |
find_text | Search for text or regex in files (oversized output dumped to result.json) |
download_file | Download a file from a URL and save it locally |
rename | Rename or move a file/directory |
copy | Copy a file or directory |
batch_edit | Batch-edit file text (only unmatched items are echoed back; successful ones are counted) |
edit | Edit text in a file |
sleep | Wait for a specified number of milliseconds |
Conditionally injected based on the active model / config:
| Tool | Injection Condition | Description |
|---|---|---|
generate_image | Only when defaultImageModel is configured | Generate an image from a text description |
view_image | Only for vision models (the agent's modelId resolves to a model with vision: true) | View / analyze an image: local images are auto-uploaded via the Files API, network URLs are referenced directly |
Tool output protection: AppConfig.maxToolOutput (in characters, default 32768) is the unified ceiling for tool output. Each tool handles overflow itself — exec dumps stdout.log / stderr.log separately (returning head/tail previews per stream), find_text / glob / ls dump result.json (returning a head preview), and read only warns and suggests batched reads via range. Dumps go to <tmpdir>/ai-zen/tool-output/<tool>-<timestamp>-<random>/, one directory per call, with no self-cleanup. See docs/sdk-design-v1.md §12.
Interrupt strategies live in the config: AppConfig.abortedMessageStrategy (keep / drop, factory keep — the model can continue the half-sentence after an interrupt) and AppConfig.abortedToolRoundStrategy (fill / drop, factory fill — whole rounds go in or out, because tool_calls must pair with results). createAgent() reads them from scope.config when no explicit option is passed, keeping a single source of truth.
Built-in Plugins
| Plugin | Description |
|---|---|
AutoMigratePlugin | Automatically triggers task migration when usage.prompt_tokens exceeds handoffTokens; delegates the actual migration to the injected HandoffService (history handled per strategy: default omit keeps it auditable, optional prune drops it). resolveMode lets the caller pick the handoff executor or skip the round. Steps aside while service.busy to avoid the handoff→migrate→handoff recursion |
AutoRefreshToolsPlugin | Re-scans the file system before each send() to refresh the tool list |
ContextGuardPlugin | Context safety guard — before each request, throws ContextOverflowError (interrupting the conversation) when the previous round's usage.prompt_tokens exceeds guardTokens (an absolute value: no multiplier, no looser tier), preventing context runaway from reading oversized files. isSuspended() lets the guard itself step aside for handoff/migration turns |
UnknownToolHintPlugin | Smarter unknown-tool hints — when the LLM calls a nonexistent tool, guides it to call_mcp_tool / points out permission issues based on MCP config (opt-in via agent.use) |
Language
The SDK is English-only: every model-facing and user-facing string — tool schema descriptions, factory prompts, runtime-injected guidance, tool results, error messages, factory catalog data — is a fixed English literal. There is no locale field, no message catalog, and no runtime language switch. UI language belongs entirely to each consumer (the CLI's i18n/, the Desktop's language setting).
This is a performance boundary, not a convenience choice — SDK behaviour and performance must not vary by the user's language:
- Thresholds are absolute.
Model.tokenThresholds.handoff/guardandAppConfig.maxToolOutputare absolute token / character counts. A localized tool schema is a different token count, so handoff and context-guard would trip at different amounts of real content for the sameconfig.json— same config, different behaviour per language. - Schema cost is paid every turn. Description text is re-sent with every request, so a token delta is a standing cost in request size, context usage and spend rather than a one-off.
- Cache prefixes stop being comparable. Prompt caching matches on the prefix; letting language into the SDK multiplies the prefix shapes of one code path and removes the single variable that benchmarks rely on.
- Diagnosis needs one shape. Performance data, bug reproductions and tool-call success rates have to be comparable across users; once behaviour drifts with locale, every conclusion needs a "what language are you on?" qualifier first.
Following the user's language is handled by one language-neutral instruction in the factory system prompt — Always reply in the same language the user writes in. — which adapts per message rather than per installation, and needs no locale plumbing in the SDK. Full rationale: docs/sdk-design-v1.md §1.
Design Principles
See the project-root PRINCIPLES.md:
- Logical consistency
- Design first, documentation as the source of truth
- Symmetry and uniformity
- No over-engineering
- Occam's razor
- Refactor as you go; keep clean layering
- Tests are the foundation