├── docs
├── postcss.config.mjs
├── content
│ └── docs
│ │ ├── examples
│ │ ├── meta.json
│ │ └── index.mdx
│ │ ├── core-concepts
│ │ ├── loop-interceptors
│ │ │ └── meta.json
│ │ ├── tools-and-mcp
│ │ │ ├── meta.json
│ │ │ └── programmatic-tool-calling.mdx
│ │ └── meta.json
│ │ ├── meta.json
│ │ ├── test.mdx
│ │ ├── index.mdx
│ │ └── quick-start.mdx
├── src
│ ├── app
│ │ ├── global.css
│ │ ├── (home)
│ │ │ └── layout.tsx
│ │ ├── api
│ │ │ └── search
│ │ │ │ └── route.ts
│ │ ├── docs
│ │ │ ├── layout.tsx
│ │ │ └── [[...slug]]
│ │ │ │ └── page.tsx
│ │ └── layout.tsx
│ ├── lib
│ │ ├── source.ts
│ │ └── layout.shared.tsx
│ ├── mdx-components.tsx
│ └── components
│ │ ├── CopyButton.tsx
│ │ └── AnimatedText.tsx
├── next.config.mjs
├── .gitignore
├── source.config.ts
├── biome.json
├── package.json
├── tsconfig.json
└── README.md
├── packages
├── agent
│ ├── src
│ │ ├── tools
│ │ │ ├── codeExecutor
│ │ │ │ ├── mod.ts
│ │ │ │ ├── protocol.ts
│ │ │ │ ├── executeCode.ts
│ │ │ │ ├── worker.ts
│ │ │ │ └── ExecuteCodeTool.ts
│ │ │ ├── fs
│ │ │ │ ├── DeleteFileTool.ts
│ │ │ │ ├── mod.ts
│ │ │ │ ├── ListDirTool.ts
│ │ │ │ ├── FileSearchTool.ts
│ │ │ │ ├── CopyFileTool.ts
│ │ │ │ ├── GrepSearchTool.ts
│ │ │ │ └── ReadFileTool.ts
│ │ │ ├── RunTerminalCmdTool.ts
│ │ │ └── mod.ts
│ │ ├── llm
│ │ │ ├── mod.ts
│ │ │ ├── utils.ts
│ │ │ └── ModelProvider.ts
│ │ ├── storage
│ │ │ ├── mod.ts
│ │ │ ├── utils.ts
│ │ │ ├── StorageErrors.ts
│ │ │ ├── FileAttachmentManager.ts
│ │ │ └── StorageService.ts
│ │ ├── loopInterceptors
│ │ │ ├── errorDetection
│ │ │ │ ├── mod.ts
│ │ │ │ ├── interface.ts
│ │ │ │ └── utils.ts
│ │ │ ├── mod.ts
│ │ │ ├── LoopInterceptorManager.ts
│ │ │ ├── interface.ts
│ │ │ ├── MaxTokensInterceptor.ts
│ │ │ ├── ToolExecutionInterceptor.ts
│ │ │ └── ErrorDetectionInterceptor.ts
│ │ ├── mod.ts
│ │ ├── utils
│ │ │ ├── mod.ts
│ │ │ ├── completer.ts
│ │ │ ├── EmittingMessageArray.ts
│ │ │ ├── tokenUsage.ts
│ │ │ ├── context.ts
│ │ │ └── data.ts
│ │ ├── error.ts
│ │ ├── mcp
│ │ │ ├── mod.ts
│ │ │ ├── InMemoryOAuthProvider.ts
│ │ │ └── utils.ts
│ │ ├── factory.ts
│ │ ├── TaskEvents.ts
│ │ └── message.ts
│ ├── tests
│ │ ├── storageUtils.test.ts
│ │ ├── completer.test.ts
│ │ ├── codeExecutor
│ │ │ └── worker.test.ts
│ │ └── connect.integration.test.ts
│ └── deno.json
├── acp
│ ├── deno.json
│ └── src
│ │ ├── mod.ts
│ │ ├── main.ts
│ │ ├── server.ts
│ │ └── content.ts
└── cli
│ ├── deno.json
│ └── src
│ ├── mod.ts
│ ├── CliOAuthCallbackHandler.ts
│ ├── runAgentInTerminal.ts
│ └── main.ts
├── examples
├── ptc
│ ├── deno.json
│ └── ptc.ts
└── mcp
│ ├── deno.json
│ └── connectToRemoteServer.example.ts
├── deno.json
├── .env.example
├── .github
└── workflows
│ ├── publish.yml
│ ├── build.yml
│ └── release.yml
├── README.md
├── .gitignore
└── CLAUDE.md
/docs/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | '@tailwindcss/postcss': {},
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/packages/agent/src/tools/codeExecutor/mod.ts:
--------------------------------------------------------------------------------
1 | export { createExecuteCodeTool } from "./ExecuteCodeTool.ts";
2 |
--------------------------------------------------------------------------------
/docs/content/docs/examples/meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "Examples",
3 | "pages": [
4 | "basic-research-agent"
5 | ]
6 | }
--------------------------------------------------------------------------------
/docs/src/app/global.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 | @import 'fumadocs-ui/css/neutral.css';
3 | @import 'fumadocs-ui/css/preset.css';
4 |
--------------------------------------------------------------------------------
/packages/agent/src/llm/mod.ts:
--------------------------------------------------------------------------------
1 | export * from "./ModelProvider.ts";
2 | export * from "./Anthropic.ts";
3 | export * from "./OpenAI.ts";
4 |
--------------------------------------------------------------------------------
/docs/content/docs/core-concepts/loop-interceptors/meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "Loop Interceptors",
3 | "pages": [
4 | "index",
5 | "built-in-interceptors"
6 | ]
7 | }
--------------------------------------------------------------------------------
/docs/content/docs/core-concepts/tools-and-mcp/meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "Tools & MCP",
3 | "pages": [
4 | "index",
5 | "built-in-tools",
6 | "programmatic-tool-calling"
7 | ]
8 | }
--------------------------------------------------------------------------------
/docs/content/docs/core-concepts/meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "Core Concepts",
3 | "pages": [
4 | "llm-providers",
5 | "tools-and-mcp",
6 | "checkpoints",
7 | "loop-interceptors"
8 | ]
9 | }
--------------------------------------------------------------------------------
/docs/content/docs/meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "Documentation",
3 | "pages": [
4 | "index",
5 | "quick-start",
6 | "core-concepts",
7 | "guides",
8 | "examples"
9 | ]
10 | }
--------------------------------------------------------------------------------
/packages/agent/src/storage/mod.ts:
--------------------------------------------------------------------------------
1 | export * from "./FileAttachmentManager.ts";
2 | export * from "./StorageService.ts";
3 | export * from "./S3StorageService.ts";
4 | export * from "./StorageErrors.ts";
5 | export * from "./utils.ts";
6 |
--------------------------------------------------------------------------------
/docs/next.config.mjs:
--------------------------------------------------------------------------------
1 | import { createMDX } from 'fumadocs-mdx/next';
2 |
3 | const withMDX = createMDX();
4 |
5 | /** @type {import('next').NextConfig} */
6 | const config = {
7 | reactStrictMode: true,
8 | };
9 |
10 | export default withMDX(config);
11 |
--------------------------------------------------------------------------------
/docs/src/app/(home)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { HomeLayout } from 'fumadocs-ui/layouts/home';
2 | import { baseOptions } from '@/lib/layout.shared';
3 |
4 | export default function Layout({ children }: LayoutProps<'/'>) {
5 | return {children};
6 | }
7 |
--------------------------------------------------------------------------------
/docs/src/app/api/search/route.ts:
--------------------------------------------------------------------------------
1 | import { source } from '@/lib/source';
2 | import { createFromSource } from 'fumadocs-core/search/server';
3 |
4 | export const { GET } = createFromSource(source, {
5 | // https://docs.orama.com/docs/orama-js/supported-languages
6 | language: 'english',
7 | });
8 |
--------------------------------------------------------------------------------
/examples/ptc/deno.json:
--------------------------------------------------------------------------------
1 | {
2 | "imports": {
3 | "@std/dotenv": "jsr:@std/dotenv@^0.225.5",
4 | "@std/streams": "jsr:@std/streams@^1.0.14",
5 | "chalk": "npm:chalk@^5.6.2",
6 | "rxjs-for-await": "npm:rxjs-for-await@^1.0.0",
7 | "zod": "npm:zod@^4.1.13"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packages/agent/src/storage/utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Generate a unique file ID that can be used across different storage implementations
3 | * @returns A unique string that will serve as the unique file identifier
4 | */
5 | export function generateFileId(): string {
6 | return crypto.randomUUID();
7 | }
8 |
--------------------------------------------------------------------------------
/packages/acp/deno.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@zypher/acp",
3 | "version": "0.1.0",
4 | "license": "Apache-2.0",
5 | "exports": {
6 | ".": "./src/mod.ts"
7 | },
8 | "imports": {
9 | "acp": "npm:@agentclientprotocol/sdk@^0.12.0",
10 | "rxjs-for-await": "npm:rxjs-for-await@^1.0.0"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/docs/src/lib/source.ts:
--------------------------------------------------------------------------------
1 | import { docs } from '@/.source';
2 | import { loader } from 'fumadocs-core/source';
3 |
4 | // See https://fumadocs.vercel.app/docs/headless/source-api for more info
5 | export const source = loader({
6 | // it assigns a URL to your pages
7 | baseUrl: '/docs',
8 | source: docs.toFumadocsSource(),
9 | });
10 |
--------------------------------------------------------------------------------
/packages/agent/src/loopInterceptors/errorDetection/mod.ts:
--------------------------------------------------------------------------------
1 | // Export all interfaces and types
2 | export * from "./interface.ts";
3 |
4 | // Export JavaScript error detectors
5 | export {
6 | ESLintErrorDetector,
7 | TypeScriptErrorDetector,
8 | } from "./TypeScriptErrorDetector.ts";
9 |
10 | // Export utility functions
11 | export { extractErrorOutput } from "./utils.ts";
12 |
--------------------------------------------------------------------------------
/docs/content/docs/test.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Components
3 | description: Components
4 | ---
5 |
6 | ## Code Block
7 |
8 | ```js
9 | console.log('Hello World');
10 | ```
11 |
12 | ## Cards
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/docs/src/mdx-components.tsx:
--------------------------------------------------------------------------------
1 | import defaultMdxComponents from 'fumadocs-ui/mdx';
2 | import type { MDXComponents } from 'mdx/types';
3 |
4 | // use this function to get MDX components, you will need it for rendering MDX
5 | export function getMDXComponents(components?: MDXComponents): MDXComponents {
6 | return {
7 | ...defaultMdxComponents,
8 | ...components,
9 | };
10 | }
11 |
--------------------------------------------------------------------------------
/docs/src/app/docs/layout.tsx:
--------------------------------------------------------------------------------
1 | import { DocsLayout } from 'fumadocs-ui/layouts/docs';
2 | import { baseOptions } from '@/lib/layout.shared';
3 | import { source } from '@/lib/source';
4 |
5 | export default function Layout({ children }: LayoutProps<'/docs'>) {
6 | return (
7 |
8 | {children}
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | # Claude
2 | .claude/
3 |
4 | # deps
5 | /node_modules
6 |
7 | # generated content
8 | .contentlayer
9 | .content-collections
10 | .source
11 |
12 | # test & build
13 | /coverage
14 | /.next/
15 | /out/
16 | /build
17 | *.tsbuildinfo
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 | /.pnp
23 | .pnp.js
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # others
29 | .env*.local
30 | .vercel
31 | next-env.d.ts
--------------------------------------------------------------------------------
/packages/cli/deno.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@zypher/cli",
3 | "version": "0.1.1",
4 | "license": "Apache-2.0",
5 | "exports": {
6 | ".": "./src/mod.ts"
7 | },
8 | "imports": {
9 | "@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.8",
10 | "@std/dotenv": "jsr:@std/dotenv@^0.225.5",
11 | "chalk": "npm:chalk@^5.6.2",
12 | "rxjs-for-await": "npm:rxjs-for-await@^1.0.0"
13 | },
14 | "tasks": {
15 | "start": "deno run -A src/mod.ts"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/packages/agent/src/mod.ts:
--------------------------------------------------------------------------------
1 | // Public entry point for Zypher Agent SDK
2 |
3 | // Core agent
4 | export * from "./ZypherAgent.ts";
5 | export * from "./factory.ts";
6 | export * from "./CheckpointManager.ts";
7 | export * from "./error.ts";
8 | export * from "./message.ts";
9 | export * from "./TaskEvents.ts";
10 |
11 | // Modules
12 | export * from "./llm/mod.ts";
13 | export * from "./loopInterceptors/mod.ts";
14 | export * from "./mcp/mod.ts";
15 | export * from "./storage/mod.ts";
16 | export * from "./utils/mod.ts";
17 |
--------------------------------------------------------------------------------
/docs/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import '@/app/global.css';
2 | import { RootProvider } from 'fumadocs-ui/provider';
3 | import { Inter } from 'next/font/google';
4 |
5 | const inter = Inter({
6 | subsets: ['latin'],
7 | });
8 |
9 | export default function Layout({ children }: LayoutProps<'/'>) {
10 | return (
11 |
12 |
13 | {children}
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/docs/source.config.ts:
--------------------------------------------------------------------------------
1 | import {
2 | defineConfig,
3 | defineDocs,
4 | frontmatterSchema,
5 | metaSchema,
6 | } from 'fumadocs-mdx/config';
7 |
8 | // You can customise Zod schemas for frontmatter and `meta.json` here
9 | // see https://fumadocs.dev/docs/mdx/collections#define-docs
10 | export const docs = defineDocs({
11 | docs: {
12 | schema: frontmatterSchema,
13 | },
14 | meta: {
15 | schema: metaSchema,
16 | },
17 | });
18 |
19 | export default defineConfig({
20 | mdxOptions: {
21 | // MDX options
22 | },
23 | });
24 |
--------------------------------------------------------------------------------
/deno.json:
--------------------------------------------------------------------------------
1 | {
2 | "workspace": [
3 | "packages/agent",
4 | "packages/cli",
5 | "packages/acp",
6 | "examples/mcp",
7 | "examples/ptc"
8 | ],
9 | "tasks": {
10 | "cli": "deno run -A packages/cli/src/mod.ts",
11 | "test": "deno test -A --trace-leaks",
12 | "test:watch": "deno test -A --watch",
13 | "checkall": "deno fmt && deno lint && deno check ."
14 | },
15 | "lint": {
16 | "rules": {
17 | "tags": ["recommended", "jsr"]
18 | }
19 | },
20 | "exclude": ["dist/", "node_modules/", "npm/", "docs/"]
21 | }
22 |
--------------------------------------------------------------------------------
/packages/agent/tests/storageUtils.test.ts:
--------------------------------------------------------------------------------
1 | import { assertNotEquals } from "@std/assert";
2 | import { generateFileId } from "../src/storage/utils.ts";
3 |
4 | Deno.test("generateFileId - generates unique ids when called multiple times", () => {
5 | const fileId1 = generateFileId();
6 | const fileId2 = generateFileId();
7 | const fileId3 = generateFileId();
8 |
9 | assertNotEquals(fileId1, fileId2, "Generated IDs are not unique");
10 | assertNotEquals(fileId1, fileId3, "Generated IDs are not unique");
11 | assertNotEquals(fileId2, fileId3, "Generated IDs are not unique");
12 | });
13 |
--------------------------------------------------------------------------------
/packages/agent/src/loopInterceptors/mod.ts:
--------------------------------------------------------------------------------
1 | // Export interfaces and types
2 | export * from "./interface.ts";
3 |
4 | // Export manager
5 | export { LoopInterceptorManager } from "./LoopInterceptorManager.ts";
6 |
7 | // Export built-in interceptors
8 | export { ErrorDetectionInterceptor } from "./ErrorDetectionInterceptor.ts";
9 | export {
10 | MaxTokensInterceptor,
11 | type MaxTokensInterceptorOptions,
12 | } from "./MaxTokensInterceptor.ts";
13 | export { ToolExecutionInterceptor } from "./ToolExecutionInterceptor.ts";
14 |
15 | // Export error detection
16 | export * from "./errorDetection/mod.ts";
17 |
--------------------------------------------------------------------------------
/packages/acp/src/mod.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * ACP (Agent Client Protocol) Module
3 | *
4 | * Provides ACP protocol support for Zypher Agent, enabling integration
5 | * with ACP-compatible clients like Zed Editor.
6 | *
7 | * Uses the official @agentclientprotocol/sdk for protocol handling.
8 | *
9 | * Run directly as CLI:
10 | * deno run -A jsr:@zypher/acp
11 | *
12 | * @module
13 | */
14 |
15 | export {
16 | runAcpServer,
17 | type RunAcpServerOptions,
18 | type ZypherAgentBuilder,
19 | } from "./server.ts";
20 |
21 | if (import.meta.main) {
22 | const { main } = await import("./main.ts");
23 | main();
24 | }
25 |
--------------------------------------------------------------------------------
/packages/cli/src/mod.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * CLI utilities for running Zypher Agent in terminal environments.
3 | *
4 | * @example Run as CLI
5 | * ```bash
6 | * deno run -A jsr:@zypher/cli -k YOUR_API_KEY
7 | * ```
8 | *
9 | * @example Import as library
10 | * ```typescript
11 | * import { runAgentInTerminal } from "@zypher/cli";
12 | * ```
13 | *
14 | * @module
15 | */
16 |
17 | export { runAgentInTerminal } from "./runAgentInTerminal.ts";
18 | export { CliOAuthCallbackHandler } from "./CliOAuthCallbackHandler.ts";
19 |
20 | if (import.meta.main) {
21 | const { main } = await import("./main.ts");
22 | main();
23 | }
24 |
--------------------------------------------------------------------------------
/packages/agent/src/storage/StorageErrors.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Base error class for storage-related errors
3 | */
4 | export class StorageError extends Error {
5 | constructor(message: string) {
6 | super(message);
7 | this.name = this.constructor.name;
8 | }
9 | }
10 |
11 | /**
12 | * Error thrown when a file does not exist, cannot be found, or has expired
13 | */
14 | export class FileNotFoundError extends StorageError {
15 | constructor(fileId: string, reason?: string) {
16 | const message = reason
17 | ? `File ${fileId} not found or expired: ${reason}`
18 | : `File ${fileId} not found or expired`;
19 | super(message);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # Example environment variables file
2 | # Copy this to .env for local development
3 | # Do not commit .env to version control
4 |
5 | # Anthropic API Key
6 | ANTHROPIC_API_KEY=
7 |
8 | # OpenAI API Key
9 | OPENAI_API_KEY=
10 |
11 | # S3 Storage Settings
12 | S3_ACCESS_KEY_ID=
13 | S3_SECRET_ACCESS_KEY=
14 | S3_REGION=
15 | S3_BUCKET_NAME=
16 | # Optional
17 | # For R2, the endpoint is https://.r2.cloudflarestorage.com
18 | # For S3, the endpoint is automatically generated
19 | S3_ENDPOINT=
20 | S3_CUSTOM_DOMAIN=
21 |
22 | # MCP Store Configuration (CoreSpeed MCP Registry)
23 | # Optional: Override the MCP Store base URL (e.g., for private registries)
24 | # Default: https://api1.mcp.corespeed.io
25 | MCP_STORE_BASE_URL=
--------------------------------------------------------------------------------
/examples/mcp/deno.json:
--------------------------------------------------------------------------------
1 | {
2 | "imports": {
3 | "@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.8",
4 | "@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@^1.25.1",
5 | "@types/react": "npm:@types/react@^19.2.7",
6 | "ink": "npm:ink@^6.5.1",
7 | "ink-select-input": "npm:ink-select-input@^6.2.0",
8 | "ink-text-input": "npm:ink-text-input@^6.0.0",
9 | "react": "npm:react@^19.2.3",
10 | "react-dom/server": "npm:react-dom@^19.2.3/server",
11 | "xstate": "npm:xstate@^5.25.0"
12 | },
13 | "lint": {
14 | "rules": {
15 | "tags": ["react"]
16 | }
17 | },
18 | "compilerOptions": {
19 | "types": [
20 | "react",
21 | "@types/react"
22 | ],
23 | "jsx": "react-jsx",
24 | "jsxImportSource": "react"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/docs/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
3 | "vcs": {
4 | "enabled": true,
5 | "clientKind": "git",
6 | "useIgnoreFile": true
7 | },
8 | "files": {
9 | "ignoreUnknown": true,
10 | "includes": ["**", "!node_modules", "!.next", "!dist", "!build", "!.source"]
11 | },
12 | "formatter": {
13 | "enabled": true,
14 | "indentStyle": "space",
15 | "indentWidth": 2
16 | },
17 | "linter": {
18 | "enabled": true,
19 | "rules": {
20 | "recommended": true
21 | },
22 | "domains": {
23 | "next": "recommended",
24 | "react": "recommended"
25 | }
26 | },
27 | "assist": {
28 | "actions": {
29 | "source": {
30 | "organizeImports": "on"
31 | }
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/packages/agent/src/llm/utils.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod";
2 |
3 | /**
4 | * Injects output schema into tool description.
5 | * Used until model provider APIs natively support outputSchema.
6 | *
7 | * @param description - The original tool description
8 | * @param outputSchema - Optional Zod schema for the tool's output
9 | * @returns The description with appended JSON schema if outputSchema is provided,
10 | * otherwise returns the original description unchanged
11 | */
12 | export function injectOutputSchema(
13 | description: string,
14 | outputSchema?: z.ZodType,
15 | ): string {
16 | if (!outputSchema) {
17 | return description;
18 | }
19 | const outputJsonSchema = z.toJSONSchema(outputSchema);
20 | return `${description}\n\n## Output Schema\n\`\`\`json\n${
21 | JSON.stringify(outputJsonSchema, null, 2)
22 | }\n\`\`\``;
23 | }
24 |
--------------------------------------------------------------------------------
/packages/agent/src/utils/mod.ts:
--------------------------------------------------------------------------------
1 | export * from "./data.ts";
2 | export * from "./prompt.ts";
3 | export * from "./completer.ts";
4 | export * from "./context.ts";
5 | export * from "./EmittingMessageArray.ts";
6 | export * from "./tokenUsage.ts";
7 |
8 | /**
9 | * Safely runs a command and guarantees it returns its output,
10 | * throwing an error if it fails.
11 | *
12 | * @param command The command to run
13 | * @param options Command options
14 | * @returns Command output
15 | * @throws Error if the command fails
16 | */
17 | export async function runCommand(
18 | command: string,
19 | options?: Deno.CommandOptions,
20 | ): Promise {
21 | const output = await new Deno.Command(command, options).output();
22 | if (!output.success) {
23 | throw new Error(`Command failed with exit code ${output.code}: ${command}`);
24 | }
25 | return output;
26 | }
27 |
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "zypher-agent-docs",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "build": "next build",
7 | "dev": "next dev --turbo",
8 | "start": "next start",
9 | "postinstall": "fumadocs-mdx",
10 | "lint": "biome check",
11 | "format": "biome format --write"
12 | },
13 | "dependencies": {
14 | "next": "15.5.9",
15 | "react": "^19.1.1",
16 | "react-dom": "^19.1.1",
17 | "fumadocs-ui": "15.7.11",
18 | "fumadocs-core": "15.7.11",
19 | "fumadocs-mdx": "11.9.0"
20 | },
21 | "devDependencies": {
22 | "@types/node": "24.3.1",
23 | "@types/react": "^19.1.12",
24 | "@types/react-dom": "^19.1.9",
25 | "typescript": "^5.9.2",
26 | "@types/mdx": "^2.0.13",
27 | "@tailwindcss/postcss": "^4.1.13",
28 | "tailwindcss": "^4.1.13",
29 | "postcss": "^8.5.6",
30 | "@biomejs/biome": "^2.2.3"
31 | }
32 | }
--------------------------------------------------------------------------------
/packages/agent/src/loopInterceptors/errorDetection/interface.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Interface for error detectors.
3 | * Each detector is responsible for checking a specific type of error.
4 | */
5 | export interface ErrorDetector {
6 | /** Unique name of the detector */
7 | name: string;
8 |
9 | /** Description of what this detector checks for */
10 | description: string;
11 |
12 | /**
13 | * Check if this detector is applicable for the current project
14 | * @param workingDirectory The directory to check in
15 | * @returns Promise True if this detector should be run
16 | */
17 | isApplicable(workingDirectory: string): Promise;
18 |
19 | /**
20 | * Run the error detection
21 | * @param workingDirectory The directory to run detection in
22 | * @returns Promise Error message if errors found, null otherwise
23 | */
24 | detect(workingDirectory: string): Promise;
25 | }
26 |
--------------------------------------------------------------------------------
/packages/agent/src/utils/completer.ts:
--------------------------------------------------------------------------------
1 | import { AbortError } from "../error.ts";
2 |
3 | export class Completer {
4 | readonly #promise: Promise;
5 | #resolve!: (value: T) => void;
6 | #reject!: (reason?: unknown) => void;
7 |
8 | constructor() {
9 | this.#promise = new Promise((res, rej) => {
10 | this.#resolve = res;
11 | this.#reject = rej;
12 | });
13 | }
14 |
15 | wait(options?: { signal?: AbortSignal }): Promise {
16 | if (options?.signal) {
17 | if (options.signal.aborted) {
18 | this.#reject(new AbortError("Operation aborted"));
19 | return this.#promise;
20 | }
21 |
22 | options.signal.addEventListener("abort", () => {
23 | this.#reject(new AbortError("Operation aborted"));
24 | });
25 | }
26 | return this.#promise;
27 | }
28 |
29 | resolve(value: T) {
30 | this.#resolve(value);
31 | }
32 |
33 | reject(reason?: unknown) {
34 | this.#reject(reason);
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/docs/src/lib/layout.shared.tsx:
--------------------------------------------------------------------------------
1 | import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
2 |
3 | /**
4 | * Shared layout configurations
5 | *
6 | * you can customise layouts individually from:
7 | * Home Layout: app/(home)/layout.tsx
8 | * Docs Layout: app/docs/layout.tsx
9 | */
10 | export function baseOptions(): BaseLayoutProps {
11 | return {
12 | nav: {
13 | title: (
14 | <>
15 |
23 | Zypher Agent
24 | >
25 | ),
26 | },
27 | // see https://fumadocs.dev/docs/ui/navigation/links
28 | links: [
29 | {
30 | text: 'API Reference',
31 | url: 'https://jsr.io/@zypher/agent/doc',
32 | external: true,
33 | },
34 | ],
35 | };
36 | }
37 |
--------------------------------------------------------------------------------
/docs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "target": "ESNext",
5 | "lib": [
6 | "dom",
7 | "dom.iterable",
8 | "esnext"
9 | ],
10 | "allowJs": true,
11 | "skipLibCheck": true,
12 | "strict": true,
13 | "forceConsistentCasingInFileNames": true,
14 | "noEmit": true,
15 | "esModuleInterop": true,
16 | "module": "esnext",
17 | "moduleResolution": "bundler",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "jsx": "preserve",
21 | "incremental": true,
22 | "paths": {
23 | "@/.source": [
24 | "./.source/index.ts"
25 | ],
26 | "@/*": [
27 | "./src/*"
28 | ]
29 | },
30 | "plugins": [
31 | {
32 | "name": "next"
33 | }
34 | ]
35 | },
36 | "include": [
37 | "next-env.d.ts",
38 | "**/*.ts",
39 | "**/*.tsx",
40 | ".next/types/**/*.ts"
41 | ],
42 | "exclude": [
43 | "node_modules"
44 | ]
45 | }
--------------------------------------------------------------------------------
/packages/agent/src/tools/fs/DeleteFileTool.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { createTool, type Tool, type ToolExecutionContext } from "../mod.ts";
3 | import * as path from "@std/path";
4 |
5 | export const DeleteFileTool: Tool<{
6 | targetFile: string;
7 | explanation?: string | undefined;
8 | }> = createTool({
9 | name: "delete_file",
10 | description: "Deletes a file at the specified path.",
11 | schema: z.object({
12 | targetFile: z
13 | .string()
14 | .describe(
15 | "The path of the file to delete (relative or absolute).",
16 | ),
17 | explanation: z
18 | .string()
19 | .optional()
20 | .describe(
21 | "One sentence explanation as to why this tool is being used, and how it contributes to the goal.",
22 | ),
23 | }),
24 | execute: async ({ targetFile }, ctx: ToolExecutionContext) => {
25 | const resolved = path.resolve(ctx.workingDirectory, targetFile);
26 | await Deno.remove(resolved);
27 | return `Successfully deleted file: ${resolved}`;
28 | },
29 | });
30 |
--------------------------------------------------------------------------------
/packages/agent/src/error.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Custom error class for abort operations
3 | */
4 | export class AbortError extends Error {
5 | constructor(
6 | message = "The operation was aborted",
7 | options?: { cause?: unknown },
8 | ) {
9 | super(message, options);
10 | this.name = "AbortError";
11 | }
12 | }
13 |
14 | /**
15 | * Custom error class for task concurrency issues
16 | * Thrown when attempting to run a new task while another task is already running
17 | */
18 | export class TaskConcurrencyError extends Error {
19 | constructor(message: string) {
20 | super(message);
21 | this.name = "TaskConcurrencyError";
22 | }
23 | }
24 |
25 | export function isAbortError(error: unknown): boolean {
26 | return error instanceof AbortError ||
27 | (
28 | error instanceof Error &&
29 | (
30 | error.name === "AbortError" ||
31 | error.message.includes("abort")
32 | )
33 | );
34 | }
35 |
36 | /**
37 | * Formats an error into a consistent string message
38 | * @param error - The error to format
39 | * @returns A string representation of the error
40 | */
41 | export function formatError(error: unknown): string {
42 | return error instanceof Error ? error.message : String(error);
43 | }
44 |
--------------------------------------------------------------------------------
/packages/agent/deno.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@zypher/agent",
3 | "version": "0.7.2",
4 | "license": "Apache-2.0",
5 | "exports": {
6 | ".": "./src/mod.ts",
7 | "./tools": "./src/tools/mod.ts"
8 | },
9 | "imports": {
10 | "@corespeed/mcp-store-client": "jsr:@corespeed/mcp-store-client@^1.9.0",
11 | "@zypher/": "./src/",
12 | "@aws-sdk/client-s3": "npm:@aws-sdk/client-s3@^3.952.0",
13 | "@aws-sdk/s3-request-presigner": "npm:@aws-sdk/s3-request-presigner@^3.952.0",
14 | "@std/assert": "jsr:@std/assert@^1.0.16",
15 | "@std/crypto": "jsr:@std/crypto@^1.0.5",
16 | "@std/dotenv": "jsr:@std/dotenv@^0.225.5",
17 | "@std/encoding": "jsr:@std/encoding@^1.0.10",
18 | "@std/expect": "jsr:@std/expect@^1.0.17",
19 | "@std/fs": "jsr:@std/fs@^1.0.20",
20 | "@std/path": "jsr:@std/path@^1.1.3",
21 | "@std/testing": "jsr:@std/testing@^1.0.16",
22 | "@openai/openai": "npm:openai@6.9.1",
23 | "@anthropic-ai/sdk": "npm:@anthropic-ai/sdk@^0.71.2",
24 | "@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@^1.25.0",
25 | "diff": "npm:diff@8.0.2",
26 | "rxjs": "npm:rxjs@^7.8.2",
27 | "rxjs-for-await": "npm:rxjs-for-await@^1.0.0",
28 | "xstate": "npm:xstate@^5.25.0",
29 | "zod": "npm:zod@^4.2.0"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/packages/cli/src/CliOAuthCallbackHandler.ts:
--------------------------------------------------------------------------------
1 | import type { OAuthCallbackHandler } from "@zypher/agent";
2 |
3 | /**
4 | * CLI-based OAuth callback handler
5 | *
6 | * This handler prompts the user to paste the callback URL after completing
7 | * the OAuth authorization in their browser. It extracts the authorization
8 | * code from the callback URL parameters.
9 | */
10 | export class CliOAuthCallbackHandler implements OAuthCallbackHandler {
11 | waitForCallback(): Promise {
12 | const input = prompt("After authorization, paste the callback URL here: ");
13 |
14 | if (!input?.trim()) {
15 | throw new Error("No callback URL provided");
16 | }
17 |
18 | // Parse the callback URL to extract authorization code
19 | let callbackUrl: URL;
20 | try {
21 | callbackUrl = new URL(input.trim());
22 | } catch {
23 | throw new Error("Invalid callback URL format");
24 | }
25 |
26 | const code = callbackUrl.searchParams.get("code");
27 | const error = callbackUrl.searchParams.get("error");
28 | if (code) {
29 | return Promise.resolve(code);
30 | } else if (error) {
31 | throw new Error(`OAuth authorization failed: ${error}`);
32 | } else {
33 | throw new Error("No authorization code or error found in callback URL");
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/docs/src/components/CopyButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 |
5 | interface CopyButtonProps {
6 | text: string;
7 | className?: string;
8 | }
9 |
10 | export function CopyButton({ text, className = '' }: CopyButtonProps) {
11 | const [copied, setCopied] = useState(false);
12 |
13 | const handleCopy = async () => {
14 | await navigator.clipboard.writeText(text);
15 | setCopied(true);
16 | setTimeout(() => setCopied(false), 2000);
17 | };
18 |
19 | return (
20 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/packages/agent/src/mcp/mod.ts:
--------------------------------------------------------------------------------
1 | export * from "./McpClient.ts";
2 | export * from "./McpServerManager.ts";
3 |
4 | export * from "./utils.ts";
5 | export * from "./connect.ts";
6 |
7 | export * from "./InMemoryOAuthProvider.ts";
8 |
9 | /** Command configuration for local MCP server execution */
10 | export interface McpCommandConfig {
11 | /** Command to execute the MCP server */
12 | command: string;
13 | /** Arguments to pass to the command */
14 | args?: string[];
15 | /** Environment variables for the server */
16 | env?: Record;
17 | }
18 |
19 | /** Remote connection configuration for external MCP servers */
20 | export interface McpRemoteConfig {
21 | /** Connection URL for the remote server */
22 | url: string;
23 | /** Custom headers for the connection */
24 | headers?: Record;
25 | }
26 |
27 | /** Server endpoint information for connecting to an MCP server */
28 | export type McpServerEndpoint =
29 | & {
30 | /** Kebab-case identifier used as key (e.g., "github-copilot") */
31 | id: string;
32 | /** Human-readable display name (e.g., "GitHub Copilot") */
33 | displayName?: string;
34 | }
35 | & (
36 | | {
37 | type: "command";
38 | /** CLI command configuration for local server execution */
39 | command: McpCommandConfig;
40 | }
41 | | {
42 | type: "remote";
43 | /** Remote server configuration for HTTP/SSE connections */
44 | remote: McpRemoteConfig;
45 | }
46 | );
47 |
--------------------------------------------------------------------------------
/packages/agent/src/tools/fs/mod.ts:
--------------------------------------------------------------------------------
1 | import type { Tool } from "../mod.ts";
2 | import { ReadFileTool } from "./ReadFileTool.ts";
3 | import { ListDirTool } from "./ListDirTool.ts";
4 | import { createEditFileTools } from "./EditFileTool.ts";
5 | import { GrepSearchTool } from "./GrepSearchTool.ts";
6 | import { FileSearchTool } from "./FileSearchTool.ts";
7 | import { CopyFileTool } from "./CopyFileTool.ts";
8 | import { DeleteFileTool } from "./DeleteFileTool.ts";
9 |
10 | export {
11 | CopyFileTool,
12 | createEditFileTools,
13 | DeleteFileTool,
14 | FileSearchTool,
15 | GrepSearchTool,
16 | ListDirTool,
17 | ReadFileTool,
18 | };
19 |
20 | /**
21 | * Creates all built-in filesystem tools for easy registration.
22 | *
23 | * @param options - Optional configuration for the filesystem tools
24 | * @param options.backupDir - Custom backup directory for edit tools.
25 | * If not provided, defaults to {workspaceDataDir}/backup.
26 | * @returns An array of all filesystem tools ready for registration
27 | *
28 | * @example
29 | * ```ts
30 | * const agent = await createZypherAgent({
31 | * modelProvider,
32 | * tools: [...createFileSystemTools()],
33 | * });
34 | * ```
35 | */
36 | export function createFileSystemTools(
37 | options?: { backupDir?: string },
38 | ): Tool[] {
39 | return [
40 | ReadFileTool,
41 | ListDirTool,
42 | ...createEditFileTools(options?.backupDir),
43 | GrepSearchTool,
44 | FileSearchTool,
45 | CopyFileTool,
46 | DeleteFileTool,
47 | ];
48 | }
49 |
--------------------------------------------------------------------------------
/docs/src/components/AnimatedText.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useEffect } from 'react';
4 |
5 | interface AnimatedTextProps {
6 | texts: string[];
7 | interval?: number;
8 | className?: string;
9 | }
10 |
11 | export default function AnimatedText({
12 | texts,
13 | interval = 2500,
14 | className = ""
15 | }: AnimatedTextProps) {
16 | const [currentIndex, setCurrentIndex] = useState(0);
17 |
18 | useEffect(() => {
19 | if (texts.length <= 1) return;
20 |
21 | const timer = setInterval(() => {
22 | setCurrentIndex((prev) => (prev + 1) % texts.length);
23 | }, interval);
24 |
25 | return () => clearInterval(timer);
26 | }, [texts.length, interval]);
27 |
28 | if (texts.length === 0) return null;
29 | if (texts.length === 1) return {texts[0]};
30 |
31 | return (
32 |
33 |
37 | {texts[currentIndex]}
38 |
39 |
54 |
55 | );
56 | }
--------------------------------------------------------------------------------
/packages/agent/src/tools/fs/ListDirTool.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { createTool, type Tool, type ToolExecutionContext } from "../mod.ts";
3 | import * as path from "@std/path";
4 |
5 | export const ListDirTool: Tool<{
6 | targetPath: string;
7 | explanation?: string | undefined;
8 | }> = createTool({
9 | name: "list_dir",
10 | description:
11 | "List the contents of a directory. The quick tool to use for discovery, before using more targeted tools like semantic search or file reading. Useful to try to understand the file structure before diving deeper into specific files. Can be used to explore the codebase.",
12 | schema: z.object({
13 | targetPath: z
14 | .string()
15 | .describe("Path to list contents of (relative or absolute)."),
16 | explanation: z
17 | .string()
18 | .optional()
19 | .describe(
20 | "One sentence explanation as to why this tool is being used, and how it contributes to the goal.",
21 | ),
22 | }),
23 | execute: async ({ targetPath }, ctx: ToolExecutionContext) => {
24 | const entries: string[] = [];
25 | const basePath = path.resolve(ctx.workingDirectory, targetPath);
26 | for await (const entry of Deno.readDir(basePath)) {
27 | const fullPath = path.join(basePath, entry.name);
28 | const fileInfo = await Deno.stat(fullPath);
29 | const type = entry.isDirectory ? "directory" : "file";
30 | const size = fileInfo.size;
31 | entries.push(`[${type}] ${entry.name} (${size} bytes)`);
32 | }
33 | return entries.join("\n");
34 | },
35 | });
36 |
--------------------------------------------------------------------------------
/docs/src/app/docs/[[...slug]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { source } from '@/lib/source';
2 | import {
3 | DocsBody,
4 | DocsDescription,
5 | DocsPage,
6 | DocsTitle,
7 | } from 'fumadocs-ui/page';
8 | import type { Metadata } from 'next';
9 | import { notFound } from 'next/navigation';
10 | import { createRelativeLink } from 'fumadocs-ui/mdx';
11 | import { getMDXComponents } from '@/mdx-components';
12 |
13 | export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
14 | const params = await props.params;
15 | const page = source.getPage(params.slug);
16 | if (!page) notFound();
17 |
18 | const MDXContent = page.data.body;
19 |
20 | return (
21 |
22 | {page.data.title}
23 | {page.data.description}
24 |
25 |
31 |
32 |
33 | );
34 | }
35 |
36 | export async function generateStaticParams() {
37 | return source.generateParams();
38 | }
39 |
40 | export async function generateMetadata(
41 | props: PageProps<'/docs/[[...slug]]'>,
42 | ): Promise {
43 | const params = await props.params;
44 | const page = source.getPage(params.slug);
45 | if (!page) notFound();
46 |
47 | return {
48 | title: page.data.title,
49 | description: page.data.description,
50 | };
51 | }
52 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | # Publish packages to JSR
2 | #
3 | # Triggers:
4 | # - release: [published] - For manually created GitHub releases
5 | # - workflow_call - Called by release.yml after automated releases
6 | # (GitHub doesn't trigger workflows from events created by GITHUB_TOKEN)
7 | # - workflow_dispatch - Manual trigger for testing
8 | #
9 | # See: https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow
10 |
11 | name: Publish to JSR
12 |
13 | on:
14 | release:
15 | types: [published]
16 | workflow_call:
17 | workflow_dispatch:
18 |
19 | jobs:
20 | publish:
21 | runs-on: ubuntu-latest
22 | timeout-minutes: 30
23 |
24 | permissions:
25 | contents: read
26 | id-token: write # The OIDC ID token is used for authentication with JSR.
27 |
28 | steps:
29 | - name: Clone repository
30 | uses: actions/checkout@v4
31 |
32 | - name: Setup Deno
33 | uses: denoland/setup-deno@v2
34 | with:
35 | deno-version: v2.6.0
36 |
37 | - name: Install dependencies
38 | run: deno install --frozen=true
39 |
40 | - name: Run formatter check
41 | run: deno fmt --check
42 |
43 | - name: Run unit tests
44 | # --allow-env required for MCP SDK transitive dependencies
45 | # --allow-read required for executeCode and codeExecution worker tests
46 | # (cross-spawn/which reads OSTYPE at module load)
47 | run: deno test --allow-env --allow-read --ignore=**/*.integration.test.ts
48 |
49 | - name: Publish to JSR
50 | run: deno publish
51 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # zypher-agent-docs
2 |
3 | This is a Next.js application generated with
4 | [Create Fumadocs](https://github.com/fuma-nama/fumadocs).
5 |
6 | Run development server:
7 |
8 | ```bash
9 | npm run dev
10 | # or
11 | pnpm dev
12 | # or
13 | yarn dev
14 | ```
15 |
16 | Open http://localhost:3000 with your browser to see the result.
17 |
18 | ## Explore
19 |
20 | In the project, you can see:
21 |
22 | - `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content.
23 | - `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep.
24 |
25 | | Route | Description |
26 | | ------------------------- | ------------------------------------------------------ |
27 | | `app/(home)` | The route group for your landing page and other pages. |
28 | | `app/docs` | The documentation layout and pages. |
29 | | `app/api/search/route.ts` | The Route Handler for search. |
30 |
31 | ### Fumadocs MDX
32 |
33 | A `source.config.ts` config file has been included, you can customise different options like frontmatter schema.
34 |
35 | Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details.
36 |
37 | ## Learn More
38 |
39 | To learn more about Next.js and Fumadocs, take a look at the following
40 | resources:
41 |
42 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js
43 | features and API.
44 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
45 | - [Fumadocs](https://fumadocs.vercel.app) - learn about Fumadocs
46 |
--------------------------------------------------------------------------------
/packages/agent/src/utils/EmittingMessageArray.ts:
--------------------------------------------------------------------------------
1 | import type { Message } from "../message.ts";
2 | import type { Subject } from "rxjs";
3 | import type { TaskEvent } from "../TaskEvents.ts";
4 |
5 | /**
6 | * Creates a type-safe proxy around a Message array that automatically emits
7 | * task events when new messages are added via push().
8 | *
9 | * Auto-emission behavior:
10 | * - push() -> Emits TaskMessageEvent for each new message
11 | * - All other operations (unshift, splice, pop, shift, index assignment) -> No auto-emission
12 | *
13 | * Operations that modify existing message history (changing, removing, or inserting
14 | * messages at existing positions) do not auto-emit. Interceptors MUST emit
15 | * TaskHistoryChangedEvent via context.eventSubject.next() when they change the history.
16 | *
17 | * @param wrappedArray The existing Message array to wrap
18 | * @param eventSubject Subject to emit events through
19 | * @returns A proxied Message array with automatic emission for push() only
20 | */
21 | export function createEmittingMessageArray(
22 | wrappedArray: Message[],
23 | eventSubject: Subject,
24 | ): Message[] {
25 | return new Proxy(wrappedArray, {
26 | get(target, prop, receiver) {
27 | const value = Reflect.get(target, prop, receiver);
28 |
29 | // Override push - adds new messages (only auto-emit case)
30 | if (prop === "push") {
31 | return function (...messages: Message[]): number {
32 | const result = target.push(...messages);
33 | for (const message of messages) {
34 | eventSubject.next({ type: "message", message });
35 | }
36 | return result;
37 | };
38 | }
39 |
40 | // Default behavior for all other properties/methods
41 | return typeof value === "function" ? value.bind(target) : value;
42 | },
43 | });
44 | }
45 |
--------------------------------------------------------------------------------
/packages/agent/src/tools/fs/FileSearchTool.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { createTool, type Tool, type ToolExecutionContext } from "../mod.ts";
3 |
4 | export const FileSearchTool: Tool<{
5 | query: string;
6 | explanation: string;
7 | }> = createTool({
8 | name: "file_search",
9 | description:
10 | "Fast file search based on fuzzy matching against file path. Use if you know part of the file path but don't know where it's located exactly. Response will be capped to 10 results. Make your query more specific if need to filter results further.",
11 | schema: z.object({
12 | query: z.string().describe("Fuzzy filename to search for"),
13 | explanation: z
14 | .string()
15 | .describe(
16 | "One sentence explanation as to why this tool is being used, and how it contributes to the goal.",
17 | ),
18 | }),
19 | execute: async ({ query }, ctx: ToolExecutionContext) => {
20 | // Using fd (modern alternative to find) with fuzzy matching
21 | const command = new Deno.Command("fd", {
22 | args: ["-t", "f", "-d", "10", "-l", query],
23 | cwd: ctx.workingDirectory,
24 | });
25 |
26 | const { stdout, stderr } = await command.output();
27 | const textDecoder = new TextDecoder();
28 | const stdoutText = textDecoder.decode(stdout);
29 | const stderrText = textDecoder.decode(stderr);
30 |
31 | if (!stdoutText && !stderrText) {
32 | return "No matching files found.";
33 | }
34 |
35 | // Split results and take only first 10
36 | const files = stdoutText
37 | .split("\n")
38 | .filter(Boolean)
39 | .slice(0, 10)
40 | .map((file) => `- ${file}`)
41 | .join("\n");
42 |
43 | if (stderrText) {
44 | return `Search completed with warnings:\n${stderrText}\nMatching files:\n${files}`;
45 | }
46 |
47 | return `Matching files:\n${files}`;
48 | },
49 | });
50 |
--------------------------------------------------------------------------------
/docs/content/docs/examples/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Examples
3 | description: Real-world examples and sample implementations using Zypher Agent framework
4 | ---
5 |
6 | # Examples
7 |
8 | Learn Zypher Agent through practical examples that demonstrate real-world usage patterns and best practices.
9 |
10 | ## Getting Started Examples
11 |
12 | ### [Basic Research Agent](./basic-research-agent)
13 | Complete example showing how to build a research agent with MCP integration and web crawling capabilities. Perfect for understanding the fundamentals of Zypher Agent.
14 |
15 | ## Sample Repository
16 |
17 | For more comprehensive examples and starter templates, check out our **sample repository**:
18 |
19 | 🚀 **[Zypher Examples Repository](https://github.com/corespeed-io/zypher-examples)**
20 |
21 | The repository contains:
22 | - Ready-to-run example agents
23 | - Best practice implementations
24 | - Integration patterns with popular services
25 | - Starter templates for common use cases
26 |
27 | ## What You'll Learn
28 |
29 | Through these examples, you'll discover how to:
30 |
31 | - **Set up agents** with different LLM providers
32 | - **Integrate MCP servers** for external tool access
33 | - **Handle real-time events** with streaming responses
34 | - **Implement error handling** and recovery patterns
35 | - **Build production-ready** agent applications
36 |
37 | ## Need Help?
38 |
39 | If you have questions about any example or want to contribute your own, feel free to:
40 | - Open an issue in the [examples repository](https://github.com/corespeed-io/zypher-examples/issues)
41 | - Join our community discussions
42 | - Check the [Core Concepts](/docs/core-concepts) documentation
43 |
44 | ---
45 |
46 | **Ready to start building?** Try the [Basic Research Agent](./basic-research-agent) or explore the [sample repository](https://github.com/corespeed-io/zypher-examples) for more advanced examples.
--------------------------------------------------------------------------------
/packages/agent/src/utils/tokenUsage.ts:
--------------------------------------------------------------------------------
1 | import type { TokenUsage } from "../llm/ModelProvider.ts";
2 |
3 | /**
4 | * Add an optional number, preserving undefined if both are undefined.
5 | */
6 | function addOptional(a?: number, b?: number): number | undefined {
7 | if (a === undefined && b === undefined) return undefined;
8 | return (a ?? 0) + (b ?? 0);
9 | }
10 |
11 | /**
12 | * Add two TokenUsage objects together.
13 | * If both inputs are undefined, returns undefined.
14 | * If one is undefined, returns the other.
15 | */
16 | export function addTokenUsage(
17 | a: TokenUsage | undefined,
18 | b: TokenUsage,
19 | ): TokenUsage;
20 | export function addTokenUsage(
21 | a: TokenUsage,
22 | b: TokenUsage | undefined,
23 | ): TokenUsage;
24 | export function addTokenUsage(
25 | a: TokenUsage | undefined,
26 | b: TokenUsage | undefined,
27 | ): TokenUsage | undefined;
28 | export function addTokenUsage(
29 | a: TokenUsage | undefined,
30 | b: TokenUsage | undefined,
31 | ): TokenUsage | undefined {
32 | if (a === undefined && b === undefined) return undefined;
33 | if (a === undefined) return b;
34 | if (b === undefined) return a;
35 |
36 | return {
37 | input: {
38 | total: a.input.total + b.input.total,
39 | cacheCreation: addOptional(a.input.cacheCreation, b.input.cacheCreation),
40 | cacheRead: addOptional(a.input.cacheRead, b.input.cacheRead),
41 | },
42 | output: {
43 | total: a.output.total + b.output.total,
44 | thinking: addOptional(a.output.thinking, b.output.thinking),
45 | },
46 | total: a.total + b.total,
47 | };
48 | }
49 |
50 | /**
51 | * Sum multiple TokenUsage objects into a single aggregate.
52 | * Returns undefined if all inputs are undefined or the array is empty.
53 | */
54 | export function sumTokenUsage(
55 | ...usages: (TokenUsage | undefined)[]
56 | ): TokenUsage | undefined {
57 | return usages.reduce(addTokenUsage, undefined);
58 | }
59 |
--------------------------------------------------------------------------------
/packages/agent/src/loopInterceptors/errorDetection/utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Safely converts a value to string, handling different types appropriately
3 | *
4 | * @param value - The value to convert to string
5 | * @returns The string representation of the value
6 | */
7 | function safeToString(value: unknown): string {
8 | if (value === null || value === undefined) return "";
9 | if (typeof value === "string") return value;
10 | if (typeof value === "object") return JSON.stringify(value);
11 | // At this point, value can only be number, boolean, bigint, or symbol
12 | return (value as number | boolean | bigint | symbol).toString();
13 | }
14 |
15 | /**
16 | * Safely extracts error output from an error object
17 | *
18 | * @param {unknown} error - The error object
19 | * @param {(output: string) => string} filterFn - Function to filter the output
20 | * @returns {string} The filtered error output
21 | */
22 | export function extractErrorOutput(
23 | error: unknown,
24 | filterFn: (output: string) => string,
25 | ): string {
26 | let errorOutput = "";
27 |
28 | if (error && typeof error === "object") {
29 | // Extract stdout if available
30 | if ("stdout" in error) {
31 | const stdout = safeToString(error.stdout);
32 | const filteredStdout = filterFn(stdout);
33 | if (filteredStdout) errorOutput += filteredStdout;
34 | }
35 |
36 | // Extract stderr if available
37 | if ("stderr" in error) {
38 | const stderr = safeToString(error.stderr);
39 | const filteredStderr = filterFn(stderr);
40 | if (filteredStderr) {
41 | errorOutput += (errorOutput ? "\n" : "") + filteredStderr;
42 | }
43 | }
44 |
45 | // Extract message if available and no other output found
46 | if (!errorOutput && "message" in error) {
47 | const message = safeToString(error.message);
48 | const filteredMessage = filterFn(message);
49 | if (filteredMessage) errorOutput = filteredMessage;
50 | }
51 | }
52 |
53 | return errorOutput;
54 | }
55 |
--------------------------------------------------------------------------------
/packages/agent/src/tools/fs/CopyFileTool.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { createTool, type Tool, type ToolExecutionContext } from "../mod.ts";
3 | import * as path from "@std/path";
4 | import { fileExists } from "../../utils/mod.ts";
5 | import { ensureDir } from "@std/fs";
6 |
7 | export const CopyFileTool: Tool<{
8 | sourceFile: string;
9 | destinationFile: string;
10 | overwrite?: boolean | undefined;
11 | explanation?: string | undefined;
12 | }> = createTool({
13 | name: "copy_file",
14 | description: "Copies a file from the source path to the destination path.",
15 | schema: z.object({
16 | sourceFile: z
17 | .string()
18 | .describe("The path of the source file to copy."),
19 | destinationFile: z
20 | .string()
21 | .describe("The path where the file should be copied to."),
22 | overwrite: z
23 | .boolean()
24 | .optional()
25 | .default(false)
26 | .describe(
27 | "Whether to overwrite the destination file if it already exists.",
28 | ),
29 | explanation: z
30 | .string()
31 | .optional()
32 | .describe(
33 | "One sentence explanation as to why this tool is being used, and how it contributes to the goal.",
34 | ),
35 | }),
36 | execute: async (
37 | { sourceFile, destinationFile, overwrite },
38 | ctx: ToolExecutionContext,
39 | ) => {
40 | const srcResolved = path.resolve(ctx.workingDirectory, sourceFile);
41 | const dstResolved = path.resolve(ctx.workingDirectory, destinationFile);
42 |
43 | // Check if source file exists
44 | if (!(await fileExists(srcResolved))) {
45 | throw new Error(`Source file not found: ${srcResolved}`);
46 | }
47 |
48 | // Check if destination file already exists
49 | const destinationExists = await fileExists(dstResolved);
50 | if (destinationExists && !overwrite) {
51 | throw new Error(
52 | `Destination file already exists: ${dstResolved}. Use overwrite=true to replace it.`,
53 | );
54 | }
55 |
56 | // Create destination directory if needed
57 | await ensureDir(path.dirname(dstResolved));
58 |
59 | // Copy the file
60 | await Deno.copyFile(srcResolved, dstResolved);
61 | return `Successfully copied file from ${srcResolved} to ${dstResolved}${
62 | destinationExists ? " (overwritten)" : ""
63 | }.`;
64 | },
65 | });
66 |
--------------------------------------------------------------------------------
/packages/agent/src/tools/RunTerminalCmdTool.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { createTool, type Tool, type ToolExecutionContext } from "./mod.ts";
3 | import { exec, spawn } from "node:child_process";
4 | import { promisify } from "node:util";
5 |
6 | const execAsync = promisify(exec);
7 |
8 | export const RunTerminalCmdTool: Tool<{
9 | command: string;
10 | isBackground: boolean;
11 | requireUserApproval: boolean;
12 | explanation?: string | undefined;
13 | }> = createTool({
14 | name: "run_terminal_cmd",
15 | description:
16 | "PROPOSE a command to run on behalf of the user.\nIf you have this tool, note that you DO have the ability to run commands directly on the USER's system.\nNote that the user will have to approve the command before it is executed.\nThe user may reject it if it is not to their liking, or may modify the command before approving it. If they do change it, take those changes into account.\nThe actual command will NOT execute until the user approves it. The user may not approve it immediately. Do NOT assume the command has started running.\nIf the step is WAITING for user approval, it has NOT started running.",
17 | schema: z.object({
18 | command: z.string().describe("The terminal command to execute"),
19 | isBackground: z
20 | .boolean()
21 | .describe("Whether the command should run in background"),
22 | requireUserApproval: z
23 | .boolean()
24 | .describe("Whether user must approve before execution"),
25 | explanation: z
26 | .string()
27 | .optional()
28 | .describe("One sentence explanation for tool usage"),
29 | }),
30 | execute: async (
31 | { command, isBackground },
32 | ctx: ToolExecutionContext,
33 | ) => {
34 | if (isBackground) {
35 | // For background processes, use spawn
36 | const child = spawn(command, [], {
37 | shell: true,
38 | detached: true,
39 | stdio: "ignore",
40 | cwd: ctx.workingDirectory,
41 | });
42 | child.unref();
43 | return `Started background command: ${command}`;
44 | }
45 |
46 | const { stdout, stderr } = await execAsync(command, {
47 | cwd: ctx.workingDirectory,
48 | });
49 | if (stderr) {
50 | return `Command executed with warnings:\n${stderr}\nOutput:\n${stdout}`;
51 | }
52 | return `Command executed successfully:\n${stdout}`;
53 | },
54 | });
55 |
--------------------------------------------------------------------------------
/packages/agent/src/mcp/InMemoryOAuthProvider.ts:
--------------------------------------------------------------------------------
1 | import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
2 | import type {
3 | OAuthClientInformationFull,
4 | OAuthClientMetadata,
5 | OAuthTokens,
6 | } from "@modelcontextprotocol/sdk/shared/auth.js";
7 |
8 | /**
9 | * In-memory OAuth provider for testing and development
10 | * This implementation stores OAuth data in memory (not persistent)
11 | * Suitable for testing, development, or scenarios where persistence is not required
12 | */
13 | export class InMemoryOAuthProvider implements OAuthClientProvider {
14 | #clientInformation?: OAuthClientInformationFull = undefined;
15 | #tokens?: OAuthTokens = undefined;
16 | #codeVerifier?: string = undefined;
17 |
18 | constructor(
19 | private readonly config: {
20 | clientMetadata: OAuthClientMetadata;
21 | onRedirect?: (authorizationUrl: string) => void | Promise;
22 | },
23 | ) {}
24 |
25 | get redirectUrl(): string {
26 | return this.config.clientMetadata.redirect_uris[0];
27 | }
28 |
29 | get clientMetadata(): OAuthClientMetadata {
30 | return this.config.clientMetadata;
31 | }
32 |
33 | clientInformation(): OAuthClientInformationFull | undefined {
34 | return this.#clientInformation;
35 | }
36 |
37 | saveClientInformation(clientInformation: OAuthClientInformationFull): void {
38 | this.#clientInformation = clientInformation;
39 | }
40 |
41 | tokens(): OAuthTokens | undefined {
42 | return this.#tokens;
43 | }
44 |
45 | saveTokens(tokens: OAuthTokens): void {
46 | this.#tokens = tokens;
47 | }
48 |
49 | async redirectToAuthorization(authorizationUrl: URL): Promise {
50 | if (this.config.onRedirect) {
51 | await this.config.onRedirect(authorizationUrl.toString());
52 | }
53 | // If no onRedirect handler provided, this is a no-op
54 | }
55 |
56 | saveCodeVerifier(codeVerifier: string): void {
57 | this.#codeVerifier = codeVerifier;
58 | }
59 |
60 | codeVerifier(): string {
61 | if (!this.#codeVerifier) {
62 | throw new Error("No code verifier saved");
63 | }
64 | return this.#codeVerifier;
65 | }
66 |
67 | /**
68 | * Clears all stored OAuth data
69 | */
70 | clear(): void {
71 | this.#clientInformation = undefined;
72 | this.#tokens = undefined;
73 | this.#codeVerifier = undefined;
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/packages/agent/src/tools/mod.ts:
--------------------------------------------------------------------------------
1 | import type * as z from "zod";
2 | import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3 | import type { ZypherContext } from "../ZypherAgent.ts";
4 |
5 | /**
6 | * Base interface for tool parameters
7 | */
8 | export type BaseParams = Record;
9 |
10 | /**
11 | * Execution context provided to tools
12 | */
13 | export type ToolExecutionContext = ZypherContext;
14 |
15 | /**
16 | * The result of a tool execution
17 | */
18 | export type ToolResult = CallToolResult | string;
19 |
20 | /**
21 | * Base interface for all tools
22 | */
23 | export interface Tool {
24 | readonly name: string;
25 | readonly description: string;
26 | readonly schema: z.ZodType;
27 | readonly outputSchema?: z.ZodType;
28 | execute(
29 | input: T,
30 | ctx: ToolExecutionContext,
31 | ): Promise;
32 | }
33 |
34 | /**
35 | * [JSON schema](https://json-schema.org/draft/2020-12) for this tool's input.
36 | *
37 | * This defines the shape of the `input` that your tool accepts and that the model
38 | * will produce.
39 | */
40 | export interface InputSchema {
41 | type: "object";
42 | properties?: unknown | null;
43 | required?: Array | null;
44 | [k: string]: unknown;
45 | }
46 |
47 | /**
48 | * Helper function to create a tool with a simpler API
49 | */
50 | export function createTool(options: {
51 | name: string;
52 | description: string;
53 | schema: z.ZodType;
54 | outputSchema?: z.ZodType;
55 | execute: (
56 | params: T,
57 | ctx: ToolExecutionContext,
58 | ) => Promise;
59 | }): Tool {
60 | return {
61 | name: options.name,
62 | description: options.description,
63 | schema: options.schema,
64 | outputSchema: options.outputSchema,
65 | execute: async (input: T, ctx: ToolExecutionContext) => {
66 | // Validate input using Zod schema
67 | const validatedInput = await options.schema.parseAsync(input);
68 | return options.execute(validatedInput, ctx);
69 | },
70 | };
71 | }
72 |
73 | // Filesystem tools
74 | export * from "./fs/mod.ts";
75 |
76 | // Code executor tool
77 | export * from "./codeExecutor/mod.ts";
78 |
79 | // Other tools
80 | export { RunTerminalCmdTool } from "./RunTerminalCmdTool.ts";
81 | export { createImageTools } from "./ImageTools.ts";
82 |
--------------------------------------------------------------------------------
/packages/acp/src/main.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * ACP Server CLI Entry Point
3 | *
4 | * Starts an ACP-compatible server that can be used with editors like Zed.
5 | *
6 | * Usage:
7 | * deno run -A jsr:@zypher/acp
8 | *
9 | * Environment variables (checked in order):
10 | * OPENAI_API_KEY - Use OpenAI as the model provider (default model: gpt-4o-2024-11-20)
11 | * ANTHROPIC_API_KEY - Use Anthropic as the model provider (default model: claude-sonnet-4-20250514)
12 | * ZYPHER_MODEL - Optional: override the default model
13 | *
14 | * Zed configuration example:
15 | * {
16 | * "agent": {
17 | * "profiles": {
18 | * "zypher": {
19 | * "type": "custom",
20 | * "command": "deno",
21 | * "args": ["run", "-A", "jsr:@zypher/acp"],
22 | * "env": {
23 | * "OPENAI_API_KEY": "your-api-key"
24 | * }
25 | * }
26 | * }
27 | * }
28 | * }
29 | */
30 |
31 | import {
32 | AnthropicModelProvider,
33 | createZypherAgent,
34 | type ModelProvider,
35 | OpenAIModelProvider,
36 | } from "@zypher/agent";
37 | import { createFileSystemTools, RunTerminalCmdTool } from "@zypher/agent/tools";
38 | import { type AcpClientConfig, runAcpServer } from "./server.ts";
39 |
40 | function extractModelProvider(): { provider: ModelProvider; model: string } {
41 | const openaiKey = Deno.env.get("OPENAI_API_KEY");
42 | if (openaiKey) {
43 | return {
44 | provider: new OpenAIModelProvider({ apiKey: openaiKey }),
45 | model: Deno.env.get("ZYPHER_MODEL") || "gpt-4o-2024-11-20",
46 | };
47 | }
48 |
49 | const anthropicKey = Deno.env.get("ANTHROPIC_API_KEY");
50 | if (anthropicKey) {
51 | return {
52 | provider: new AnthropicModelProvider({ apiKey: anthropicKey }),
53 | model: Deno.env.get("ZYPHER_MODEL") || "claude-sonnet-4-20250514",
54 | };
55 | }
56 |
57 | console.error(
58 | "Error: Set OPENAI_API_KEY or ANTHROPIC_API_KEY environment variable",
59 | );
60 | Deno.exit(1);
61 | }
62 |
63 | export async function main(): Promise {
64 | const { provider: modelProvider, model } = extractModelProvider();
65 |
66 | await runAcpServer(async (clientConfig: AcpClientConfig) => {
67 | return await createZypherAgent({
68 | modelProvider,
69 | tools: [...createFileSystemTools(), RunTerminalCmdTool],
70 | workingDirectory: clientConfig.cwd,
71 | mcpServers: clientConfig.mcpServers,
72 | });
73 | }, model);
74 | }
75 |
--------------------------------------------------------------------------------
/packages/acp/src/server.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * ACP Server
3 | *
4 | * Runs a Zypher ACP agent over stdio.
5 | */
6 |
7 | import * as acp from "acp";
8 | import { Completer } from "@zypher/agent";
9 | import { ZypherAcpAgent, type ZypherAgentBuilder } from "./ZypherAcpAgent.ts";
10 | export type { AcpClientConfig, ZypherAgentBuilder } from "./ZypherAcpAgent.ts";
11 |
12 | /**
13 | * Options for running an ACP server.
14 | */
15 | export interface RunAcpServerOptions {
16 | /** Custom input stream, defaults to Deno.stdin.readable */
17 | input?: ReadableStream;
18 | /** Custom output stream, defaults to Deno.stdout.writable */
19 | output?: WritableStream;
20 | /** Signal to stop the server */
21 | signal?: AbortSignal;
22 | }
23 |
24 | /**
25 | * Runs a Zypher ACP server.
26 | *
27 | * @example Basic usage
28 | * ```typescript
29 | * import { runAcpServer } from "@zypher/acp";
30 | * import { createZypherAgent, AnthropicModelProvider } from "@zypher/agent";
31 | *
32 | * const modelProvider = new AnthropicModelProvider({ apiKey: "..." });
33 | *
34 | * await runAcpServer(
35 | * async (clientConfig) => {
36 | * return await createZypherAgent({
37 | * modelProvider,
38 | * tools: [...],
39 | * workingDirectory: clientConfig.cwd,
40 | * mcpServers: clientConfig.mcpServers,
41 | * });
42 | * },
43 | * "claude-sonnet-4-20250514",
44 | * );
45 | * ```
46 | *
47 | * @example With abort signal
48 | * ```typescript
49 | * const controller = new AbortController();
50 | * setTimeout(() => controller.abort(), 60000);
51 | *
52 | * await runAcpServer(builder, model, { signal: controller.signal });
53 | * ```
54 | *
55 | * @param builder - Function that creates a ZypherAgent for each session
56 | * @param model - The model identifier to use for agent tasks
57 | * @param options - Optional configuration for streams and cancellation
58 | * @returns Promise that resolves when the connection closes
59 | */
60 | export async function runAcpServer(
61 | builder: ZypherAgentBuilder,
62 | model: string,
63 | options?: RunAcpServerOptions,
64 | ): Promise {
65 | const input = options?.input ?? Deno.stdin.readable;
66 | const output = options?.output ?? Deno.stdout.writable;
67 | const stream = acp.ndJsonStream(output, input);
68 |
69 | const connection = new acp.AgentSideConnection(
70 | (conn) => new ZypherAcpAgent(conn, builder, model),
71 | stream,
72 | );
73 |
74 | const abortCompleter = new Completer();
75 | await Promise.race([
76 | connection.closed,
77 | abortCompleter.wait({ signal: options?.signal }),
78 | ]);
79 | }
80 |
--------------------------------------------------------------------------------
/packages/agent/src/utils/context.ts:
--------------------------------------------------------------------------------
1 | import * as path from "@std/path";
2 | import { ensureDir } from "@std/fs";
3 | import { encodeBase64 } from "@std/encoding/base64";
4 | import type { ZypherContext } from "../ZypherAgent.ts";
5 |
6 | /**
7 | * Creates a ZypherContext for the given working directory.
8 | * This function consolidates the workspace directory creation logic
9 | * that was previously duplicated across the codebase.
10 | *
11 | * @param workingDirectory Absolute path to the working directory
12 | * @param options Optional configuration to override default ZypherContext values
13 | * @returns Promise resolving to ZypherContext with concrete directory paths
14 | */
15 | export async function createZypherContext(
16 | workingDirectory: string,
17 | options?: Partial>,
18 | ): Promise {
19 | // Create the base zypher directory
20 | const zypherDir = options?.zypherDir ?? getDefaultZypherDir();
21 | await ensureDir(zypherDir);
22 |
23 | // Generate workspace data directory using Base64 encoding (unless overridden)
24 | const workspaceDataDir = options?.workspaceDataDir ??
25 | getDefaultWorkspaceDataDir(
26 | zypherDir,
27 | workingDirectory,
28 | );
29 | await ensureDir(workspaceDataDir);
30 |
31 | const fileAttachmentCacheDir = options?.fileAttachmentCacheDir ??
32 | path.join(zypherDir, "cache", "files");
33 | await ensureDir(fileAttachmentCacheDir);
34 |
35 | return {
36 | workingDirectory,
37 | zypherDir,
38 | workspaceDataDir,
39 | fileAttachmentCacheDir,
40 | userId: options?.userId,
41 | };
42 | }
43 |
44 | /**
45 | * Gets the default zypher directory.
46 | * Checks ZYPHER_HOME environment variable first, then falls back to ~/.zypher
47 | */
48 | function getDefaultZypherDir(): string {
49 | const zypherHome = Deno.env.get("ZYPHER_HOME");
50 | if (zypherHome) {
51 | return zypherHome;
52 | }
53 |
54 | const homeDir = Deno.env.get("HOME");
55 | if (!homeDir) {
56 | throw new Error("Could not determine home directory");
57 | }
58 | return path.join(homeDir, ".zypher");
59 | }
60 |
61 | /**
62 | * Gets the default workspace data directory path using Base64 encoding
63 | * of the working directory path for filesystem safety.
64 | */
65 | function getDefaultWorkspaceDataDir(
66 | zypherDir: string,
67 | workingDirectory: string,
68 | ): string {
69 | // Use Base64 encoding for consistent, filesystem-safe workspace directory names
70 | const encoder = new TextEncoder();
71 | const data = encoder.encode(workingDirectory);
72 | const encodedPath = encodeBase64(data).replace(/[/+]/g, "_").replace(
73 | /=/g,
74 | "",
75 | );
76 |
77 | return path.join(zypherDir, encodedPath);
78 | }
79 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Zypher Agent
2 |
3 | **Production-ready AI agents that live in your applications**
4 |
5 | [](https://github.com/corespeed-io/zypher-agent/actions/workflows/build.yml)
6 | [](https://jsr.io/badges/@zypher/agent)
7 |
8 | ## Features
9 |
10 | - **Agent, Not Workflow**: Reactive loop where the agent dynamically decides
11 | next steps based on LLM reasoning.
12 | - **Git-Based Checkpoints**: Track, review, and revert agent changes with
13 | built-in checkpoint management
14 | - **Extensible Tool System**: Built-in tools for file operations, search, and
15 | terminal commands with support for custom tools
16 | - **Model Context Protocol (MCP)**: Native support for MCP servers with OAuth
17 | authentication
18 | - **Multi-Provider Support**: Works with Anthropic Claude and OpenAI GPT models
19 | through a unified interface
20 | - **Loop Interceptor System**: Customize agent behavior with extensible
21 | post-inference interceptors
22 | - **Production-Ready**: Configurable timeouts, concurrency protection, and
23 | comprehensive error handling
24 |
25 | ## Quick Start
26 |
27 | ### Installation
28 |
29 | > [!NOTE]
30 | > Support for npm coming soon.
31 |
32 | #### Using JSR
33 |
34 | ```bash
35 | deno add jsr:@zypher/agent
36 | ```
37 |
38 | ### SDK Usage
39 |
40 | ```typescript
41 | import { AnthropicModelProvider, createZypherAgent } from "@zypher/agent";
42 | import { createFileSystemTools } from "@zypher/agent/tools";
43 | import { eachValueFrom } from "rxjs-for-await";
44 |
45 | const agent = await createZypherAgent({
46 | modelProvider: new AnthropicModelProvider({
47 | apiKey: Deno.env.get("ANTHROPIC_API_KEY")!,
48 | }),
49 | tools: [...createFileSystemTools()],
50 | mcpServers: ["@modelcontextprotocol/sequentialthinking-server"],
51 | });
52 |
53 | // Run task with streaming
54 | const taskEvents = agent.runTask(
55 | "Implement authentication middleware",
56 | "claude-sonnet-4-20250514",
57 | );
58 |
59 | for await (const event of eachValueFrom(taskEvents)) {
60 | console.log(event);
61 | }
62 | ```
63 |
64 | See our [documentation](https://zypher.corespeed.io/docs) for full usage
65 | examples and API reference.
66 |
67 | ## License
68 |
69 | Licensed under the Apache License, Version 2.0. See [LICENSE.md](LICENSE.md) for
70 | details.
71 |
72 | ## Resources
73 |
74 | - [Documentation](https://zypher.corespeed.io/docs) and
75 | [API Reference](https://jsr.io/@zypher/agent/doc)
76 | - [Issue Tracker](https://github.com/corespeed-io/zypher-agent/issues)
77 | - [Model Context Protocol](https://modelcontextprotocol.io/)
78 |
79 | ---
80 |
81 | Built with ♥️ by [CoreSpeed](https://corespeed.io)
82 |
--------------------------------------------------------------------------------
/packages/agent/src/tools/codeExecutor/protocol.ts:
--------------------------------------------------------------------------------
1 | import z from "zod";
2 | import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
3 |
4 | // ============================================================================
5 | // Host -> Worker Messages
6 | // ============================================================================
7 |
8 | export const ExecuteMessageSchema = z.object({
9 | type: z.literal("execute"),
10 | code: z.string(),
11 | tools: z
12 | .array(z.string().describe("The name of the tool"))
13 | .describe("The available tools to use"),
14 | });
15 |
16 | export const ToolResultSchema = z.union([z.string(), CallToolResultSchema]);
17 |
18 | export const ToolResponseMessageSchema = z.object({
19 | type: z.literal("tool_response"),
20 | toolUseId: z.string(),
21 | toolName: z.string(),
22 | result: ToolResultSchema,
23 | });
24 |
25 | export const ToolErrorMessageSchema = z.object({
26 | type: z.literal("tool_error"),
27 | toolUseId: z.string(),
28 | toolName: z.string(),
29 | error: z.unknown(),
30 | });
31 |
32 | export const HostToWorkerMessageSchema = z.discriminatedUnion("type", [
33 | ExecuteMessageSchema,
34 | ToolResponseMessageSchema,
35 | ToolErrorMessageSchema,
36 | ]);
37 |
38 | // ============================================================================
39 | // Worker -> Host Messages
40 | // ============================================================================
41 |
42 | export const ToolUseMessageSchema = z.object({
43 | type: z.literal("tool_use"),
44 | toolUseId: z.string(),
45 | toolName: z.string(),
46 | input: z.unknown(),
47 | });
48 |
49 | export const CodeExecutionResultSchema = z.object({
50 | type: z.literal("code_execution_result"),
51 | success: z.boolean(),
52 | data: z.unknown().optional(),
53 | error: z.unknown().optional(),
54 | logs: z.array(z.string()),
55 | });
56 |
57 | export const WorkerToHostMessageSchema = z.discriminatedUnion("type", [
58 | ToolUseMessageSchema,
59 | CodeExecutionResultSchema,
60 | ]);
61 |
62 | // ============================================================================
63 | // Type Exports
64 | // ============================================================================
65 |
66 | export type ExecuteMessage = z.infer;
67 | export type ToolResponseMessage = z.infer;
68 | export type ToolErrorMessage = z.infer;
69 | export type HostToWorkerMessage = z.infer;
70 |
71 | export type ToolUseMessage = z.infer;
72 | export type CodeExecutionResult = z.infer;
73 | export type WorkerToHostMessage = z.infer;
74 |
--------------------------------------------------------------------------------
/packages/agent/src/utils/data.ts:
--------------------------------------------------------------------------------
1 | import * as path from "@std/path";
2 | import type { Message } from "../message.ts";
3 | import { isMessage } from "../message.ts";
4 | import { formatError } from "../error.ts";
5 | import type { ZypherContext } from "../ZypherAgent.ts";
6 |
7 | /**
8 | * Checks if a file exists and is readable.
9 | *
10 | * @param {string} path - Path to the file to check
11 | * @returns {Promise} True if file exists and is readable, false otherwise
12 | */
13 | export async function fileExists(path: string): Promise {
14 | try {
15 | await Deno.stat(path);
16 | return true;
17 | } catch {
18 | return false;
19 | }
20 | }
21 |
22 | /**
23 | * Loads the message history for the current workspace.
24 | * Each workspace has its own message history file based on its path.
25 | *
26 | * @returns {Promise} Array of messages from history, empty array if no history exists
27 | */
28 | export async function loadMessageHistory(
29 | context: ZypherContext,
30 | ): Promise {
31 | try {
32 | const historyPath = path.join(context.workspaceDataDir, "history.json");
33 |
34 | // Check if file exists before trying to read it
35 | if (!(await fileExists(historyPath))) {
36 | return [];
37 | }
38 |
39 | const content = await Deno.readTextFile(historyPath);
40 | const parsedData: unknown = JSON.parse(content);
41 |
42 | // Validate that parsedData is an array
43 | if (!Array.isArray(parsedData)) {
44 | console.warn("Message history is not an array, returning empty array");
45 | return [];
46 | }
47 |
48 | // Filter out invalid messages using the isMessage type guard
49 | const messages: Message[] = parsedData.filter((item): item is Message => {
50 | const valid = isMessage(item);
51 | if (!valid) {
52 | console.warn(
53 | "Found invalid message in history, filtering it out:",
54 | item,
55 | );
56 | }
57 | return valid;
58 | });
59 |
60 | return messages;
61 | } catch (error) {
62 | console.warn(
63 | `Failed to load message history: ${
64 | formatError(error)
65 | }, falling back to empty history`,
66 | );
67 | return [];
68 | }
69 | }
70 |
71 | /**
72 | * Saves the message history for the current workspace.
73 | * Creates a new history file if it doesn't exist, or updates the existing one.
74 | *
75 | * @param {Message[]} messages - Array of messages to save
76 | * @returns {Promise}
77 | */
78 | export async function saveMessageHistory(
79 | messages: Message[],
80 | context: ZypherContext,
81 | ): Promise {
82 | const historyPath = path.join(context.workspaceDataDir, "history.json");
83 | await Deno.writeTextFile(historyPath, JSON.stringify(messages, null, 2));
84 | }
85 |
--------------------------------------------------------------------------------
/packages/acp/src/content.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * ACP/MCP Content Block Converter
3 | *
4 | * Converts ACP ContentBlock[] to ZypherAgent prompt format.
5 | * Follows MCP resource specification for consistent handling.
6 | *
7 | * Note: Content block `annotations` are intentionally ignored as ZypherAgent
8 | * does not currently use display hints or audience targeting metadata.
9 | */
10 |
11 | import type { ContentBlock as AcpContentBlock } from "acp";
12 |
13 | export interface PromptContent {
14 | text: string;
15 | }
16 |
17 | /**
18 | * Converts ACP content blocks to ZypherAgent prompt format.
19 | */
20 | export function convertPromptContent(blocks: AcpContentBlock[]): PromptContent {
21 | const parts: string[] = [];
22 |
23 | for (const block of blocks) {
24 | const result = convertBlock(block);
25 | if (result.text) parts.push(result.text);
26 | }
27 |
28 | return { text: parts.join("\n\n") };
29 | }
30 |
31 | function convertBlock(
32 | block: AcpContentBlock,
33 | ): { text: string } {
34 | switch (block.type) {
35 | case "text":
36 | return { text: block.text };
37 |
38 | case "resource":
39 | return convertResource(block.resource);
40 |
41 | case "resource_link":
42 | return { text: formatResourceLink(block) };
43 |
44 | case "audio":
45 | return { text: `[Audio: ${block.mimeType}, not transcribed]` };
46 |
47 | case "image":
48 | return { text: `[Image: ${block.mimeType}], not supported` };
49 |
50 | default:
51 | return {
52 | text: `[Unsupported content: ${(block as { type: string }).type}]`,
53 | };
54 | }
55 | }
56 |
57 | function convertResource(resource: {
58 | uri: string;
59 | mimeType?: string | null;
60 | text?: string;
61 | blob?: string;
62 | }): { text: string } {
63 | const { uri, text } = resource;
64 |
65 | if (text !== undefined) {
66 | const mimeType = resource.mimeType ?? "text/plain";
67 | return {
68 | text: `\n${text}\n`,
69 | };
70 | }
71 |
72 | if (resource.blob !== undefined) {
73 | return { text: `[Binary: ${getFilename(uri)} (${resource.mimeType})]` };
74 | }
75 |
76 | return { text: `[Resource: ${uri}]` };
77 | }
78 |
79 | function formatResourceLink(block: {
80 | uri: string;
81 | name: string;
82 | mimeType?: string | null;
83 | title?: string | null;
84 | description?: string | null;
85 | size?: number | bigint | null;
86 | }): string {
87 | const lines = [`[File: ${block.title ?? block.name}]`, `URI: ${block.uri}`];
88 | if (block.mimeType) lines.push(`Type: ${block.mimeType}`);
89 | if (block.description) lines.push(`Description: ${block.description}`);
90 | return lines.join("\n");
91 | }
92 |
93 | function getFilename(uri: string): string {
94 | return uri.split("/").pop() ?? "file";
95 | }
96 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 | .pnpm-debug.log*
9 |
10 | # MCP Configuration
11 | mcp.json
12 | .server-status.json
13 |
14 | # Diagnostic reports (https://nodejs.org/api/report.html)
15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
16 |
17 | # Runtime data
18 | pids
19 | *.pid
20 | *.seed
21 | *.pid.lock
22 |
23 | # Directory for instrumented libs generated by jscoverage/JSCover
24 | lib-cov
25 |
26 | # Coverage directory used by tools like istanbul
27 | coverage
28 | *.lcov
29 |
30 | # nyc test coverage
31 | .nyc_output
32 |
33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
34 | .grunt
35 |
36 | # Bower dependency directory (https://bower.io/)
37 | bower_components
38 |
39 | # node-waf configuration
40 | .lock-wscript
41 |
42 | # Compiled binary addons (https://nodejs.org/api/addons.html)
43 | build/Release
44 |
45 | # Dependency directories
46 | node_modules/
47 | jspm_packages/
48 |
49 | # Snowpack dependency directory (https://snowpack.dev/)
50 | web_modules/
51 |
52 | # TypeScript cache
53 | *.tsbuildinfo
54 |
55 | # Optional npm cache directory
56 | .npm
57 |
58 | # Optional eslint cache
59 | .eslintcache
60 |
61 | # Optional stylelint cache
62 | .stylelintcache
63 |
64 | # Microbundle cache
65 | .rpt2_cache/
66 | .rts2_cache_cjs/
67 | .rts2_cache_es/
68 | .rts2_cache_umd/
69 |
70 | # Optional REPL history
71 | .node_repl_history
72 |
73 | # Output of 'npm pack'
74 | *.tgz
75 |
76 | # Yarn Integrity file
77 | .yarn-integrity
78 |
79 | # dotenv environment variable files
80 | .env
81 | .env.development.local
82 | .env.test.local
83 | .env.production.local
84 | .env.local
85 |
86 | # parcel-bundler cache (https://parceljs.org/)
87 | .cache
88 | .parcel-cache
89 |
90 | # Next.js build output
91 | .next
92 | out
93 |
94 | # Nuxt.js build / generate output
95 | .nuxt
96 | dist
97 |
98 | # Gatsby files
99 | .cache/
100 | # Comment in the public line in if your project uses Gatsby and not Next.js
101 | # https://nextjs.org/blog/next-9-1#public-directory-support
102 | # public
103 |
104 | # vuepress build output
105 | .vuepress/dist
106 |
107 | # vuepress v2.x temp and cache directory
108 | .temp
109 | .cache
110 |
111 | # vitepress build output
112 | **/.vitepress/dist
113 |
114 | # vitepress cache directory
115 | **/.vitepress/cache
116 |
117 | # Docusaurus cache and generated files
118 | .docusaurus
119 |
120 | # Serverless directories
121 | .serverless/
122 |
123 | # FuseBox cache
124 | .fusebox/
125 |
126 | # DynamoDB Local files
127 | .dynamodb/
128 |
129 | # TernJS port file
130 | .tern-port
131 |
132 | # Stores VSCode versions used for testing VSCode extensions
133 | .vscode-test
134 |
135 | # yarn v2
136 | .yarn/cache
137 | .yarn/unplugged
138 | .yarn/build-state.yml
139 | .yarn/install-state.gz
140 | .pnp.*
141 |
142 | # ide
143 | .idea/
144 | .vscode/
145 |
146 | # npm publish artifacts
147 | npm/
148 |
149 | .claude/
--------------------------------------------------------------------------------
/packages/agent/src/tools/fs/GrepSearchTool.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { createTool, type Tool, type ToolExecutionContext } from "../mod.ts";
3 |
4 | export const GrepSearchTool: Tool<{
5 | query: string;
6 | caseSensitive?: boolean | undefined;
7 | includePattern?: string | undefined;
8 | excludePattern?: string | undefined;
9 | explanation?: string | undefined;
10 | }> = createTool({
11 | name: "grep_search",
12 | description:
13 | "Fast text-based regex search that finds exact pattern matches within files or directories, utilizing the ripgrep command for efficient searching.\nResults will be formatted in the style of ripgrep and can be configured to include line numbers and content.\nTo avoid overwhelming output, the results are capped at 50 matches.\nUse the include or exclude patterns to filter the search scope by file type or specific paths.\n\nThis is best for finding exact text matches or regex patterns.\nMore precise than semantic search for finding specific strings or patterns.\nThis is preferred over semantic search when we know the exact symbol/function name/etc. to search in some set of directories/file types.",
14 | schema: z.object({
15 | query: z.string().describe("The regex pattern to search for"),
16 | caseSensitive: z
17 | .boolean()
18 | .optional()
19 | .describe("Whether the search should be case sensitive"),
20 | includePattern: z
21 | .string()
22 | .optional()
23 | .describe(
24 | "Glob pattern for files to include (e.g. '*.ts' for TypeScript files)",
25 | ),
26 | excludePattern: z
27 | .string()
28 | .optional()
29 | .describe("Glob pattern for files to exclude"),
30 | explanation: z
31 | .string()
32 | .optional()
33 | .describe(
34 | "One sentence explanation as to why this tool is being used, and how it contributes to the goal.",
35 | ),
36 | }),
37 | execute: async (
38 | { query, caseSensitive, includePattern, excludePattern },
39 | ctx: ToolExecutionContext,
40 | ) => {
41 | // Build the arguments array for ripgrep
42 | const args = ["--line-number", "--no-heading"];
43 |
44 | if (!caseSensitive) {
45 | args.push("-i");
46 | }
47 |
48 | if (includePattern) {
49 | args.push("-g", includePattern);
50 | }
51 |
52 | if (excludePattern) {
53 | args.push("-g", `!${excludePattern}`);
54 | }
55 |
56 | // Add max count to avoid overwhelming output
57 | args.push("-m", "50");
58 |
59 | // Add the search query
60 | args.push(query);
61 |
62 | // Execute the command
63 | const command = new Deno.Command("rg", {
64 | args: args,
65 | cwd: ctx.workingDirectory,
66 | });
67 |
68 | const { stdout, stderr } = await command.output();
69 | const textDecoder = new TextDecoder();
70 | const stdoutText = textDecoder.decode(stdout);
71 | const stderrText = textDecoder.decode(stderr);
72 |
73 | if (!stdoutText && !stderrText) {
74 | return "No matches found.";
75 | }
76 | if (stderrText) {
77 | return `Search completed with warnings:\n${stderrText}\nResults:\n${stdoutText}`;
78 | }
79 | return stdoutText;
80 | },
81 | });
82 |
--------------------------------------------------------------------------------
/packages/agent/src/factory.ts:
--------------------------------------------------------------------------------
1 | import type { ModelProvider } from "./llm/mod.ts";
2 | import type { McpServerEndpoint } from "./mcp/mod.ts";
3 | import {
4 | ZypherAgent,
5 | type ZypherAgentOptions,
6 | type ZypherContext,
7 | } from "./ZypherAgent.ts";
8 | import { createZypherContext } from "./utils/context.ts";
9 |
10 | /**
11 | * Options for creating a ZypherAgent using the simplified factory function.
12 | * Extends ZypherAgentOptions with additional factory-specific options.
13 | */
14 | export interface CreateZypherAgentOptions extends ZypherAgentOptions {
15 | /**
16 | * The AI model provider to use (Anthropic or OpenAI).
17 | * @example new AnthropicModelProvider({ apiKey: "..." })
18 | */
19 | modelProvider: ModelProvider;
20 |
21 | /**
22 | * Working directory for the agent. Defaults to Deno.cwd().
23 | */
24 | workingDirectory?: string;
25 |
26 | /**
27 | * MCP servers to register.
28 | * Accepts either:
29 | * - Package identifiers from the CoreSpeed MCP Store (e.g., "@corespeed/browser-rendering")
30 | * - Direct server endpoint configurations
31 | * @example ["@firecrawl/firecrawl", { id: "custom", type: "command", command: {...} }]
32 | */
33 | mcpServers?: (string | McpServerEndpoint)[];
34 |
35 | /**
36 | * Override context settings (userId, custom directories).
37 | */
38 | context?: Partial>;
39 | }
40 |
41 | /**
42 | * Creates a ZypherAgent with simplified initialization.
43 | *
44 | * This factory function wraps the multi-step agent creation process into a single call,
45 | * handling context creation, tool registration, and MCP server connections.
46 | *
47 | * @example
48 | * ```typescript
49 | * const agent = await createZypherAgent({
50 | * modelProvider: new AnthropicModelProvider({ apiKey }),
51 | * tools: [ReadFileTool, ListDirTool],
52 | * mcpServers: ["@firecrawl/firecrawl"],
53 | * });
54 | *
55 | * const events$ = agent.runTask("Find latest AI news", "claude-sonnet-4-20250514");
56 | * ```
57 | *
58 | * @param options Configuration options for agent creation
59 | * @returns A fully initialized ZypherAgent
60 | * @throws If any MCP server fails to register (fail-fast behavior)
61 | */
62 | export async function createZypherAgent(
63 | options: CreateZypherAgentOptions,
64 | ): Promise {
65 | // 1. Create context
66 | const workingDirectory = options.workingDirectory ?? Deno.cwd();
67 | const zypherContext = await createZypherContext(
68 | workingDirectory,
69 | options.context,
70 | );
71 |
72 | // 2. Create agent with tools
73 | const agent = new ZypherAgent(zypherContext, options.modelProvider, {
74 | storageService: options.storageService,
75 | checkpointManager: options.checkpointManager,
76 | tools: options.tools,
77 | config: options.config,
78 | overrides: options.overrides,
79 | });
80 |
81 | // 3. Register MCP servers in parallel
82 | if (options.mcpServers) {
83 | await Promise.all(
84 | options.mcpServers.map((server) =>
85 | typeof server === "string"
86 | ? agent.mcp.registerServerFromRegistry(server)
87 | : agent.mcp.registerServer(server)
88 | ),
89 | );
90 | }
91 |
92 | return agent;
93 | }
94 |
--------------------------------------------------------------------------------
/docs/content/docs/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Introduction
3 | description: Understanding the core concepts and architecture of Zypher Agent framework
4 | ---
5 |
6 | # What are Zypher Agents
7 |
8 | Zypher Agents are autonomous AI systems that can reason, plan, and execute complex tasks by combining Large Language Models (LLMs) with external tools and services.
9 |
10 | ## Core Concept
11 |
12 | Think of a Zypher Agent as an AI assistant that can:
13 | - **Understand** complex requests in natural language
14 | - **Plan** multi-step approaches to solve problems
15 | - **Execute** actions using external tools and APIs
16 | - **Learn** from results and adapt its approach
17 | - **Communicate** progress and results back to you
18 |
19 | Unlike traditional chatbots that just generate text, Zypher Agents can actually *do* things in the real world.
20 |
21 | ## Agents vs Traditional Software
22 |
23 | Traditional software follows predetermined workflows - you write code that defines exactly what happens in each scenario. Zypher Agents are different:
24 |
25 | | Traditional Software | Zypher Agents |
26 | |---------------------|---------------|
27 | | **Fixed workflows** - Hard-coded logic for each task | **Dynamic reasoning** - LLM plans the approach based on context |
28 | | **Rigid execution** - Same steps every time | **Adaptive planning** - Adjusts strategy based on results |
29 | | **Manual integration** - Developer writes code for each tool | **Automatic tool usage** - Agent discovers and uses tools as needed |
30 | | **Explicit error handling** - Pre-written code for each failure case | **Intelligent recovery** - Reasons about problems and finds solutions |
31 |
32 | With Zypher Agents, you describe *what* you want accomplished, and the LLM brain figures out *how* to do it using available tools.
33 |
34 | ## Quick Example
35 |
36 | ```typescript
37 | import { ZypherAgent, AnthropicModelProvider } from '@zypher/agent';
38 |
39 | const agent = new ZypherAgent(
40 | new AnthropicModelProvider({ apiKey: "your-key" })
41 | );
42 |
43 | await agent.init();
44 |
45 | // Agent can understand complex requests and execute them
46 | const result = await agent.runTask("Find the latest news about AI and summarize the top 3 articles");
47 | ```
48 |
49 | ## What Makes Zypher Agents Powerful
50 |
51 | ### 🧠 **LLM-Powered Reasoning**
52 | Built on state-of-the-art language models like Claude and GPT-4, giving agents sophisticated reasoning capabilities.
53 |
54 | ### 🛠️ **Tool Integration**
55 | Connect to any external service through the [Model Context Protocol (MCP)](/docs/core-concepts/tools-and-mcp) - from web scraping to database queries.
56 |
57 | ### 🔄 **Loop Interceptors**
58 | Control execution flow with [interceptors](/docs/core-concepts/loop-interceptors) for error handling, token management, and custom logic.
59 |
60 | ### 📝 **Git Checkpoints**
61 | Track changes with [automatic checkpointing](/docs/core-concepts/checkpoints) - revert to previous states when needed.
62 |
63 | ### ⚡ **Simple API**
64 | Just a few lines of code to create powerful agents. No complex configuration required.
65 |
66 | ## Get Started
67 |
68 |
69 |
70 |
71 |
72 |
73 |
--------------------------------------------------------------------------------
/packages/cli/src/runAgentInTerminal.ts:
--------------------------------------------------------------------------------
1 | import type { ZypherAgent } from "@zypher/agent";
2 | import readline from "node:readline";
3 | import { stdin, stdout } from "node:process";
4 | import chalk from "chalk";
5 | import { formatError, printMessage } from "@zypher/agent";
6 | import { eachValueFrom } from "rxjs-for-await";
7 |
8 | /**
9 | * Run the agent in a terminal interface.
10 | * @param agent - The agent to run.
11 | * @param model - The model to use.
12 | */
13 | export async function runAgentInTerminal(agent: ZypherAgent, model: string) {
14 | console.log("\n🤖 Welcome to Zypher Agent CLI!\n");
15 | console.log(`🧠 Using model: ${chalk.cyan(model)}`);
16 | console.log(
17 | 'Type your task or command below. Use "exit" or Ctrl+C to quit.\n',
18 | );
19 |
20 | const rl = readline.createInterface({ input: stdin, output: stdout });
21 | const textEncoder = new TextEncoder();
22 |
23 | try {
24 | while (true) {
25 | const task = await prompt("🔧 Enter your task: ", rl);
26 |
27 | if (task.toLowerCase() === "exit") {
28 | console.log("\nGoodbye! 👋\n");
29 | break;
30 | }
31 |
32 | if (!task.trim()) continue;
33 |
34 | console.log("\n🚀 Starting task execution...\n");
35 | try {
36 | const taskEvents = await agent.runTask(task, model);
37 | let isFirstTextChunk = true;
38 | let cancelled = false;
39 |
40 | for await (const event of eachValueFrom(taskEvents)) {
41 | if (event.type === "text") {
42 | if (isFirstTextChunk) {
43 | Deno.stdout.write(textEncoder.encode(chalk.blue("🤖 ")));
44 | isFirstTextChunk = false;
45 | }
46 |
47 | // Write the text without newline to allow continuous streaming
48 | Deno.stdout.write(textEncoder.encode(event.content));
49 | } else {
50 | isFirstTextChunk = true;
51 | }
52 |
53 | if (event.type === "message") {
54 | // Add a line between messages for better readability
55 | Deno.stdout.write(textEncoder.encode("\n"));
56 |
57 | if (event.message.role === "user") {
58 | printMessage(event.message);
59 | Deno.stdout.write(textEncoder.encode("\n"));
60 | }
61 | } else if (event.type === "tool_use") {
62 | Deno.stdout.write(
63 | textEncoder.encode(`\n\n🔧 Using tool: ${event.toolName}\n`),
64 | );
65 | } else if (event.type === "tool_use_input") {
66 | Deno.stdout.write(textEncoder.encode(event.partialInput));
67 | } else if (event.type === "cancelled") {
68 | cancelled = true;
69 | console.log("\n🛑 Task cancelled, reason: ", event.reason, "\n");
70 | }
71 | }
72 |
73 | // Add extra newlines for readability after completion
74 | Deno.stdout.write(textEncoder.encode("\n\n"));
75 |
76 | if (!cancelled) {
77 | console.log("\n✅ Task completed.\n");
78 | }
79 | } catch (error) {
80 | console.error(chalk.red("\n❌ Error:"), formatError(error));
81 | console.log("\nReady for next task.\n");
82 | }
83 | }
84 | } finally {
85 | rl.close();
86 | }
87 | }
88 |
89 | function prompt(question: string, rl: readline.Interface): Promise {
90 | return new Promise((resolve) => {
91 | rl.question(question, (answer) => {
92 | resolve(answer);
93 | });
94 | });
95 | }
96 |
--------------------------------------------------------------------------------
/packages/agent/src/storage/FileAttachmentManager.ts:
--------------------------------------------------------------------------------
1 | import * as path from "@std/path";
2 | import {
3 | type FileAttachment,
4 | isFileAttachment,
5 | type Message,
6 | } from "../message.ts";
7 | import type { StorageService } from "./StorageService.ts";
8 | import { fileExists } from "../utils/mod.ts";
9 |
10 | /**
11 | * A map of file attachment IDs to their cached file paths and signed URLs
12 | */
13 | export interface FileAttachmentCacheMap {
14 | [fileId: string]: FileAttachmentCache;
15 | }
16 |
17 | export interface FileAttachmentCache {
18 | /**
19 | * The local cache file path for the file attachment, this can be used to read the file from local file system
20 | */
21 | cachePath: string;
22 |
23 | /**
24 | * The signed URL for the file attachment, this can be used to download the file attachment from public internet
25 | */
26 | signedUrl: string;
27 | }
28 |
29 | export class FileAttachmentManager {
30 | constructor(
31 | private readonly storageService: StorageService,
32 | readonly cacheDir: string,
33 | ) {}
34 |
35 | /**
36 | * Retrieves a file attachment from storage service
37 | * @param fileId ID of the file to retrieve
38 | * @returns Promise resolving to a FileAttachment object or null if file doesn't exist or isn't supported
39 | */
40 | async getFileAttachment(fileId: string): Promise {
41 | // Get metadata and check if the file exists
42 | const metadata = await this.storageService.getFileMetadata(fileId);
43 | if (!metadata) {
44 | return null;
45 | }
46 |
47 | // Return formatted file attachment
48 | return {
49 | type: "file_attachment",
50 | fileId,
51 | mimeType: metadata.contentType,
52 | };
53 | }
54 |
55 | /**
56 | * Get the local cache file path for a file attachment
57 | * @param fileId ID of the file attachment
58 | * @returns Promise resolving to the cache file path
59 | */
60 | getFileAttachmentCachePath(fileId: string): string {
61 | return path.join(this.cacheDir, fileId);
62 | }
63 |
64 | /**
65 | * Caches all file attachments in a messages
66 | * @param messages The messages to cache file attachments from
67 | * @returns Promise resolving to a dictionary of file attachment caches
68 | */
69 | async cacheMessageFileAttachments(
70 | messages: Message[],
71 | ): Promise {
72 | const cacheDict: FileAttachmentCacheMap = {};
73 | for (const message of messages) {
74 | for (const block of message.content) {
75 | if (isFileAttachment(block)) {
76 | const cache = await this.cacheFileAttachment(block.fileId);
77 | if (cache) {
78 | cacheDict[block.fileId] = cache;
79 | }
80 | }
81 | }
82 | }
83 | return cacheDict;
84 | }
85 |
86 | /**
87 | * Caches a file attachment if it's not already cached if possible
88 | * @param fileId ID of the file attachment
89 | * @returns Promise resolving to a FileAttachmentCache object,
90 | * or null if:
91 | * - the file ID does not exist on storage service
92 | */
93 | async cacheFileAttachment(
94 | fileId: string,
95 | ): Promise {
96 | const cachePath = this.getFileAttachmentCachePath(fileId);
97 | if (!await fileExists(cachePath)) {
98 | // Download the file attachment from storage service to cache path
99 | await this.storageService.downloadFile(fileId, cachePath);
100 | }
101 |
102 | return {
103 | cachePath,
104 | signedUrl: await this.storageService.getSignedUrl(fileId),
105 | };
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/packages/agent/src/loopInterceptors/LoopInterceptorManager.ts:
--------------------------------------------------------------------------------
1 | import {
2 | type InterceptorContext,
3 | type InterceptorResult,
4 | LoopDecision,
5 | type LoopInterceptor,
6 | } from "./interface.ts";
7 | import { AbortError, formatError } from "../error.ts";
8 |
9 | /**
10 | * Manages and executes loop interceptors
11 | */
12 | export class LoopInterceptorManager {
13 | #interceptors: LoopInterceptor[];
14 |
15 | /**
16 | * Creates a new LoopInterceptorManager
17 | * @param initialInterceptors Optional array of interceptors to register immediately
18 | */
19 | constructor(initialInterceptors: LoopInterceptor[] = []) {
20 | this.#interceptors = [...initialInterceptors];
21 | }
22 |
23 | /**
24 | * Register a new loop interceptor
25 | * @param interceptor The interceptor to register
26 | */
27 | register(interceptor: LoopInterceptor): void {
28 | // Check for name conflicts
29 | if (this.#interceptors.some((i) => i.name === interceptor.name)) {
30 | throw new Error(
31 | `Loop interceptor with name '${interceptor.name}' is already registered`,
32 | );
33 | }
34 |
35 | this.#interceptors.push(interceptor);
36 | }
37 |
38 | /**
39 | * Unregister an interceptor by name
40 | * @param name The name of the interceptor to remove
41 | * @returns boolean True if interceptor was found and removed
42 | */
43 | unregister(name: string): boolean {
44 | const index = this.#interceptors.findIndex((i) => i.name === name);
45 | if (index >= 0) {
46 | this.#interceptors.splice(index, 1);
47 | return true;
48 | }
49 | return false;
50 | }
51 |
52 | /**
53 | * Get list of registered interceptor names
54 | * @returns string[] Array of interceptor names
55 | */
56 | getRegisteredNames(): string[] {
57 | return this.#interceptors.map((i) => i.name);
58 | }
59 |
60 | /**
61 | * Execute interceptors in chain of responsibility pattern
62 | * @param context The context to pass to interceptors
63 | * @returns Promise Result from the chain
64 | */
65 | async execute(
66 | context: InterceptorContext,
67 | ): Promise {
68 | // Execute interceptors sequentially until one decides to CONTINUE
69 | for (const interceptor of this.#interceptors) {
70 | // Check for abort signal
71 | if (context.signal?.aborted) {
72 | throw new AbortError("Aborted while running loop interceptors");
73 | }
74 |
75 | try {
76 | // Execute the interceptor
77 | const result = await interceptor.intercept(context);
78 |
79 | // If this interceptor wants to continue, it takes control of the chain
80 | if (result.decision === LoopDecision.CONTINUE) {
81 | return result;
82 | }
83 |
84 | // If interceptor decides to COMPLETE, continue to next interceptor
85 | // (unless it's the last one)
86 | } catch (error) {
87 | console.warn(
88 | `Error running loop interceptor '${interceptor.name}':`,
89 | formatError(error),
90 | );
91 | // Continue with next interceptor even if one fails
92 | }
93 | }
94 |
95 | // No interceptor wanted to continue the loop
96 | return {
97 | decision: LoopDecision.COMPLETE,
98 | };
99 | }
100 |
101 | /**
102 | * Clear all registered interceptors
103 | */
104 | clear(): void {
105 | this.#interceptors = [];
106 | }
107 |
108 | /**
109 | * Get count of registered interceptors
110 | * @returns number Count of registered interceptors
111 | */
112 | count(): number {
113 | return this.#interceptors.length;
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/packages/agent/src/tools/codeExecutor/executeCode.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * High-level API for executing code in an isolated Web Worker environment.
3 | *
4 | * Provides a simple interface to run TypeScript/JavaScript code with tool access
5 | * and cancellation support. Manages the worker lifecycle and message passing internally.
6 | */
7 |
8 | import type { McpServerManager } from "../../mcp/mod.ts";
9 | import { Completer } from "../../utils/mod.ts";
10 | import {
11 | type CodeExecutionResult,
12 | type HostToWorkerMessage,
13 | WorkerToHostMessageSchema,
14 | } from "./protocol.ts";
15 |
16 | export interface ExecuteCodeOptions {
17 | /**
18 | * AbortSignal to cancel the code execution. Use AbortSignal.timeout(ms) for timeouts.
19 | */
20 | signal?: AbortSignal;
21 | }
22 |
23 | /**
24 | * Executes TypeScript/JavaScript code in an isolated Web Worker environment.
25 | *
26 | * The code has access to a `tools` proxy object for calling registered tools.
27 | * Tool calls are routed through the McpServerManager for execution.
28 | *
29 | * @param code - The TypeScript/JavaScript code to execute
30 | * @param mcpServerManager - Manager providing access to registered tools
31 | * @param options - Execution options including abort signal for cancellation
32 | * @returns Promise resolving to the execution result with data, logs, and success status
33 | */
34 | export async function executeCode(
35 | code: string,
36 | mcpServerManager: McpServerManager,
37 | options: ExecuteCodeOptions = {},
38 | ): Promise {
39 | const { signal } = options;
40 |
41 | const completer = new Completer();
42 | const worker = new Worker(
43 | new URL("./worker.ts", import.meta.url),
44 | { type: "module" },
45 | );
46 |
47 | function postMessage(message: HostToWorkerMessage) {
48 | worker.postMessage(message);
49 | }
50 |
51 | try {
52 | worker.onmessage = async (event) => {
53 | const message = WorkerToHostMessageSchema.parse(event.data);
54 |
55 | if (message.type === "tool_use") {
56 | const { toolUseId, toolName, input } = message;
57 |
58 | try {
59 | const result = await mcpServerManager.callTool(
60 | toolUseId,
61 | toolName,
62 | input,
63 | );
64 | postMessage({
65 | type: "tool_response",
66 | toolUseId,
67 | toolName,
68 | result,
69 | });
70 | } catch (error) {
71 | // Send error back to worker instead of rejecting the completer here.
72 | // This allows the agent's code to handle tool errors gracefully via try/catch,
73 | // rather than terminating the entire code execution on the first tool failure.
74 | // The worker will throw this error, which the agent code can catch and handle.
75 | postMessage({
76 | type: "tool_error",
77 | toolUseId,
78 | toolName,
79 | error,
80 | });
81 | }
82 | } else {
83 | // Message is a CodeExecutionResult - the worker has finished executing the code
84 | completer.resolve(message);
85 | }
86 | };
87 |
88 | worker.onerror = (error) => {
89 | completer.resolve({
90 | type: "code_execution_result",
91 | success: false,
92 | error: error.message,
93 | logs: [],
94 | });
95 | };
96 |
97 | const toolNames = Array.from(mcpServerManager.tools.keys());
98 | postMessage({
99 | type: "execute",
100 | code,
101 | tools: toolNames,
102 | });
103 |
104 | return await completer.wait({ signal });
105 | } finally {
106 | worker.terminate();
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # CLAUDE.md
2 |
3 | This file provides guidance to Claude Code (claude.ai/code) when working with
4 | code in this repository.
5 |
6 | ## Development Commands
7 |
8 | Root workspace commands (run from repo root):
9 |
10 | - **Run CLI**: `deno task cli` - Runs the CLI from packages/cli
11 | - **Test all**: `deno task test` - Runs all tests with leak tracing
12 | - **Test watch**: `deno task test:watch` - Runs tests in watch mode
13 | - **Check all**: `deno task checkall` - Runs format, lint, and type check
14 |
15 | ### Code quality
16 |
17 | - **Type check**: `deno check .`
18 | - **Lint**: `deno lint`
19 | - **Format**: `deno fmt`
20 |
21 | ## Architecture Overview
22 |
23 | Zypher Agent is a TypeScript SDK for building production-ready AI agents.
24 | Published as `@zypher/agent` on JSR.
25 |
26 | ### Monorepo Structure
27 |
28 | ```
29 | packages/
30 | ├── agent/ # Core SDK (@zypher/agent)
31 | │ ├── src/ # Source code
32 | │ └── tests/ # Tests
33 | ├── cli/ # CLI tool (@zypher/cli)
34 | examples/
35 | └── ptc/ # Programmatic Tool Calling example
36 | ```
37 |
38 | ### Core Components (packages/agent/src/)
39 |
40 | - **ZypherAgent.ts**: Main agent class with reactive task execution loop. Uses
41 | RxJS Observable to stream TaskEvents. Manages message history, checkpoints,
42 | and coordinates with McpServerManager and LoopInterceptorManager.
43 |
44 | - **factory.ts**: `createZypherAgent()` - simplified factory that creates
45 | context, registers tools, and connects MCP servers in one call.
46 |
47 | - **llm/**: Model provider abstraction (`ModelProvider` interface) with
48 | implementations for Anthropic and OpenAI. Handles streaming chat completions
49 | and token usage tracking.
50 |
51 | - **loopInterceptors/**: Chain of responsibility pattern for post-inference
52 | processing. Each interceptor can return `LoopDecision.CONTINUE` to continue
53 | the agent loop or `LoopDecision.COMPLETE` to finish.
54 | - `ToolExecutionInterceptor`: Executes tool calls from LLM responses
55 | - `MaxTokensInterceptor`: Auto-continues when response hits token limit
56 | - `ErrorDetectionInterceptor`: Detects errors and prompts for fixes
57 |
58 | - **mcp/**: Model Context Protocol integration
59 | - `McpServerManager`: Manages MCP server lifecycle, tool registration, and
60 | tool execution with optional approval handlers
61 | - `McpClient`: Individual server connection with status observable
62 | - Supports CoreSpeed MCP Store registry for server discovery
63 |
64 | - **tools/**: Extensible tool system using Zod schemas
65 | - `fs/`: File system tools (ReadFile, ListDir, EditFile, GrepSearch, etc.)
66 | - `codeExecutor/`: Programmatic Tool Calling (PTC) via `execute_code` tool
67 | - Use `createTool()` helper to define custom tools
68 |
69 | - **CheckpointManager.ts**: Git-based workspace state snapshots. Creates a
70 | separate git repository in workspaceDataDir to track file changes without
71 | affecting the user's main repository.
72 |
73 | - **TaskEvents.ts**: Discriminated union of all events emitted during task
74 | execution (text streaming, tool use, messages, usage, completion).
75 |
76 | ### Key Patterns
77 |
78 | - **Event streaming**: `agent.runTask()` returns `Observable` for
79 | real-time updates
80 | - **Tool approval**: Optional `ToolApprovalHandler` callback before tool
81 | execution
82 | - **Context separation**: `ZypherContext` (workspace/directories) vs
83 | `ZypherAgentConfig` (behavioral settings)
84 | - **MCP Server sources**: Registry (CoreSpeed MCP Store) or direct configuration
85 |
86 | ### Testing
87 |
88 | Tests are in `packages/agent/tests/`. Integration tests require environment
89 | variables (API keys, S3 credentials). Run a single test:
90 |
91 | ```bash
92 | deno test -A packages/agent/tests/McpServerManager.test.ts
93 | ```
94 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on:
4 | push:
5 | branches: ["main"]
6 | pull_request:
7 | branches: ["main"]
8 |
9 | permissions:
10 | contents: read
11 |
12 | jobs:
13 | build:
14 | runs-on: ubuntu-latest
15 |
16 | steps:
17 | - name: Setup repo
18 | uses: actions/checkout@v4
19 |
20 | - name: Setup Deno
21 | uses: denoland/setup-deno@v2
22 | with:
23 | deno-version: v2.6.0
24 |
25 | - name: Install dependencies
26 | run: deno install --frozen=true
27 |
28 | - name: Run linter check
29 | run: deno lint
30 |
31 | - name: Run formatter check
32 | run: deno fmt --check
33 |
34 | - name: Run type check
35 | run: deno check .
36 |
37 | - name: Run unit tests
38 | # --allow-env required for MCP SDK transitive dependencies
39 | # --allow-read required for executeCode and codeExecution worker tests
40 | # (cross-spawn/which reads OSTYPE at module load)
41 | run: deno test --allow-env --allow-read --ignore=**/*.integration.test.ts
42 |
43 | - name: JSR publish dry run
44 | run: deno publish --dry-run
45 |
46 | detect-changes:
47 | needs: build
48 | if: github.event_name == 'push' && github.ref == 'refs/heads/main'
49 | runs-on: ubuntu-latest
50 | outputs:
51 | has-changes: ${{ steps.detect.outputs.has-changes }}
52 | packages: ${{ steps.detect.outputs.packages }}
53 | steps:
54 | - uses: actions/checkout@v4
55 | with:
56 | fetch-depth: 2
57 |
58 | - name: Detect version changes
59 | id: detect
60 | run: |
61 | CHANGED_PACKAGES="[]"
62 |
63 | for pkg_dir in packages/*/; do
64 | PKG_JSON="${pkg_dir}deno.json"
65 |
66 | if [ -f "$PKG_JSON" ]; then
67 | NAME=$(jq -r '.name // empty' "$PKG_JSON")
68 |
69 | # Skip packages without a name (not publishable)
70 | if [ -z "$NAME" ]; then
71 | echo "Skipping $pkg_dir (no name field)"
72 | continue
73 | fi
74 |
75 | NEW_VERSION=$(jq -r '.version // "0.0.0"' "$PKG_JSON")
76 | OLD_VERSION=$(git show HEAD~1:"$PKG_JSON" 2>/dev/null | jq -r '.version // "0.0.0"' 2>/dev/null || echo "0.0.0")
77 |
78 | if [ "$NEW_VERSION" != "$OLD_VERSION" ]; then
79 | SHORT_NAME=$(basename "$pkg_dir")
80 | CHANGED_PACKAGES=$(echo "$CHANGED_PACKAGES" | jq -c \
81 | --arg name "$NAME" \
82 | --arg version "$NEW_VERSION" \
83 | --arg path "packages/$SHORT_NAME" \
84 | --arg short_name "$SHORT_NAME" \
85 | --arg old_version "$OLD_VERSION" \
86 | '. + [{name: $name, version: $version, path: $path, short_name: $short_name, old_version: $old_version}]')
87 | echo "🚀 Version change: $NAME $OLD_VERSION → $NEW_VERSION"
88 | fi
89 | fi
90 | done
91 |
92 | if [ "$CHANGED_PACKAGES" = "[]" ]; then
93 | echo "has-changes=false" >> $GITHUB_OUTPUT
94 | echo "No version changes detected"
95 | else
96 | echo "has-changes=true" >> $GITHUB_OUTPUT
97 | echo "packages=$CHANGED_PACKAGES" >> $GITHUB_OUTPUT
98 | echo "Changed packages: $CHANGED_PACKAGES"
99 | fi
100 |
101 | release:
102 | needs: detect-changes
103 | if: needs.detect-changes.outputs.has-changes == 'true'
104 | uses: ./.github/workflows/release.yml
105 | with:
106 | packages: ${{ needs.detect-changes.outputs.packages }}
107 | permissions:
108 | contents: write
109 | id-token: write # Required for publish.yml to authenticate with JSR
110 |
--------------------------------------------------------------------------------
/packages/agent/src/llm/ModelProvider.ts:
--------------------------------------------------------------------------------
1 | import type { Observable } from "rxjs";
2 | import type { Message } from "../message.ts";
3 | import type { Tool } from "../tools/mod.ts";
4 | import type { FileAttachmentCacheMap } from "../storage/mod.ts";
5 | import type {
6 | TaskTextEvent,
7 | TaskToolUseEvent,
8 | TaskToolUseInputEvent,
9 | } from "../TaskEvents.ts";
10 |
11 | export interface ModelProviderOptions {
12 | apiKey: string;
13 | baseUrl?: string;
14 | }
15 |
16 | export interface StreamChatParams {
17 | model: string;
18 | maxTokens: number;
19 | system: string;
20 | messages: Message[];
21 | userId?: string;
22 | tools?: Tool[];
23 | signal?: AbortSignal;
24 | }
25 |
26 | /**
27 | * Provider capabilities
28 | */
29 | export type ProviderCapability =
30 | | "caching"
31 | | "thinking"
32 | | "vision"
33 | | "documents"
34 | | "tool_calling";
35 |
36 | /**
37 | * Provider information
38 | */
39 | export interface ProviderInfo {
40 | name: string;
41 | version: string;
42 | capabilities: ProviderCapability[];
43 | }
44 |
45 | /**
46 | * Abstraction that a concrete large-language-model provider (Anthropic,
47 | * OpenAI, etc.) must implement. At present only streaming chat completions
48 | * are required, but the interface can evolve as the code-base grows.
49 | */
50 | export interface ModelProvider {
51 | /**
52 | * Get provider information.
53 | */
54 | get info(): ProviderInfo;
55 |
56 | /**
57 | * Request a streaming chat completion. The returned object must satisfy the
58 | * structural `LLMStream` interface so that downstream code can interact with
59 | * it in a provider-agnostic manner.
60 | */
61 | streamChat(
62 | params: StreamChatParams,
63 | fileAttachmentCacheMap?: FileAttachmentCacheMap,
64 | ): ModelStream;
65 | }
66 |
67 | export interface ModelStream {
68 | /**
69 | * Get an observable that emits the next message in the stream.
70 | */
71 | get events(): Observable;
72 |
73 | /**
74 | * Wait for the stream to complete and get the final message
75 | */
76 | finalMessage(): Promise;
77 | }
78 |
79 | /**
80 | * Token usage information from an LLM response.
81 | */
82 | export interface TokenUsage {
83 | /** Input/prompt token usage */
84 | input: {
85 | /** Total input tokens */
86 | total: number;
87 | /** Tokens used to create new cache entries (Anthropic only) */
88 | cacheCreation?: number;
89 | /** Tokens read from cache (cache hit) */
90 | cacheRead?: number;
91 | };
92 | /** Output/completion token usage */
93 | output: {
94 | /** Total output tokens */
95 | total: number;
96 | /** Tokens used for reasoning/thinking (OpenAI reasoning models) */
97 | thinking?: number;
98 | };
99 | /** Total tokens (input.total + output.total) */
100 | total: number;
101 | }
102 |
103 | export interface FinalMessage extends Message {
104 | /**
105 | * The reason the model stopped generating.
106 | * "end_turn" - the model reached a natural stopping point
107 | * "max_tokens" - we exceeded the requested max_tokens or the model's maximum
108 | * "stop_sequence" - one of your provided custom stop_sequences was generated
109 | * "tool_use" - the model invoked one or more tools
110 | */
111 | stop_reason: "end_turn" | "max_tokens" | "stop_sequence" | "tool_use";
112 | /** Token usage for this response (undefined if provider doesn't return usage data) */
113 | usage?: TokenUsage;
114 | }
115 |
116 | /**
117 | * Event emitted when a complete message is received from the model
118 | */
119 | export interface ModelMessageEvent {
120 | type: "message";
121 | message: FinalMessage;
122 | }
123 |
124 | export type ModelEvent =
125 | | ModelMessageEvent
126 | | TaskTextEvent
127 | | TaskToolUseEvent
128 | | TaskToolUseInputEvent;
129 |
--------------------------------------------------------------------------------
/packages/agent/src/loopInterceptors/interface.ts:
--------------------------------------------------------------------------------
1 | import type { Message } from "../message.ts";
2 | import type { Tool } from "../tools/mod.ts";
3 | import type { FinalMessage } from "../llm/mod.ts";
4 | import type { Subject } from "rxjs";
5 | import type { TaskEvent } from "../TaskEvents.ts";
6 | import type { ZypherContext } from "../ZypherAgent.ts";
7 |
8 | /**
9 | * Decision made by a loop interceptor
10 | */
11 | export enum LoopDecision {
12 | /** Continue the agent loop with injected context */
13 | CONTINUE = "continue",
14 | /** Allow the agent loop to complete normally */
15 | COMPLETE = "complete",
16 | }
17 |
18 | /**
19 | * Context provided to loop interceptors
20 | */
21 | export interface InterceptorContext {
22 | /** Current conversation messages including the latest agent response */
23 | messages: Message[];
24 | /** The agent's latest response text */
25 | lastResponse: string;
26 | /** Available tools */
27 | tools: Tool[];
28 | /** The zypher context containing workspace and environment information */
29 | zypherContext: ZypherContext;
30 | /** Stop reason from the LLM response (e.g., "end_turn", "max_tokens", "tool_use") */
31 | stopReason?: FinalMessage["stop_reason"];
32 | /** Abort signal for cancellation */
33 | signal: AbortSignal;
34 | /**
35 | * RxJS Subject for emitting task events during interceptor execution.
36 | *
37 | * **Note**: Message events are automatically emitted when interceptors
38 | * add new messages using push(). Other array operations (unshift, splice, pop, shift)
39 | * do not auto-emit and require manual emission of TaskHistoryChangedEvent if needed.
40 | *
41 | * This subject can be used for custom task events like tool execution
42 | * progress, approval requests, history modifications, etc.
43 | */
44 | eventSubject: Subject;
45 | }
46 |
47 | /**
48 | * Result returned by a loop interceptor
49 | */
50 | export interface InterceptorResult {
51 | /** Decision on whether to continue or complete the loop */
52 | decision: LoopDecision;
53 | /** Optional reasoning for the decision (for debugging/logging) */
54 | reasoning?: string;
55 | }
56 |
57 | /**
58 | * Interface for loop interceptors that run after agent inference
59 | *
60 | * Interceptors can inject or modify LLM message context to influence subsequent
61 | * agent behavior (e.g., add tool results, error messages, continuation prompts).
62 | */
63 | export interface LoopInterceptor {
64 | /** Unique name of the interceptor */
65 | readonly name: string;
66 |
67 | /** Description of what this interceptor does */
68 | readonly description: string;
69 |
70 | /**
71 | * Execute the interceptor's custom logic to influence agent behavior.
72 | *
73 | * This method is called after the LLM generates a response. You are provided
74 | * an {@link InterceptorContext} containing the conversation messages, LLM response,
75 | * available tools, and other context. Use your custom logic to determine
76 | * if the agent should continue or complete the loop.
77 | *
78 | * **Modifying Message Context**: To influence subsequent agent behavior,
79 | * inject or modify messages in `context.messages`:
80 | * - Add new messages: `context.messages.push(newMessage)` (auto-emits TaskMessageEvent)
81 | * - Modify existing messages: Use unshift, splice, pop, shift, or direct assignment
82 | *
83 | * **Event Emission**:
84 | * - If you modify existing message history, you MUST emit TaskHistoryChangedEvent
85 | * via `context.eventSubject.next({ type: "history_changed" })` to notify
86 | * that this interceptor changed the history
87 | * - You MAY also emit custom events to meet your specific needs
88 | *
89 | * @param context {@link InterceptorContext} with messages, tools, and event emission capabilities
90 | * @returns {@link InterceptorResult} with {@link LoopDecision} on whether to continue or complete the loop
91 | */
92 | intercept(context: InterceptorContext): Promise;
93 | }
94 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | # Create GitHub Releases for monorepo packages
2 | #
3 | # Manual trigger (workflow_dispatch) input format:
4 | # [
5 | # {
6 | # "name": "@zypher/agent", # JSR package name
7 | # "version": "0.6.2", # Version to release
8 | # "path": "packages/agent", # Path for changelog filtering
9 | # "short_name": "agent" # Used for tag: agent/v0.6.2
10 | # }
11 | # ]
12 | #
13 | # Example for single package:
14 | # [{"name":"@zypher/agent","version":"0.6.2","path":"packages/agent","short_name":"agent"}]
15 | #
16 | # Example for multiple packages:
17 | # [{"name":"@zypher/agent","version":"0.6.2","path":"packages/agent","short_name":"agent"},{"name":"@zypher/cli","version":"0.1.1","path":"packages/cli","short_name":"cli"}]
18 |
19 | name: Create GitHub Releases
20 |
21 | on:
22 | workflow_call:
23 | inputs:
24 | packages:
25 | description: "JSON array of packages to release"
26 | required: true
27 | type: string
28 | workflow_dispatch:
29 | inputs:
30 | packages:
31 | description: "JSON array of packages (see workflow file for format)"
32 | required: true
33 | type: string
34 |
35 | jobs:
36 | release:
37 | runs-on: ubuntu-latest
38 | permissions:
39 | contents: write
40 | strategy:
41 | matrix:
42 | package: ${{ fromJson(inputs.packages) }}
43 | steps:
44 | - uses: actions/checkout@v4
45 | with:
46 | fetch-depth: 0
47 |
48 | - name: Create git tag
49 | run: |
50 | git config user.name "github-actions[bot]"
51 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
52 | TAG="${{ matrix.package.short_name }}/v${{ matrix.package.version }}"
53 | git tag -a "$TAG" -m "Release ${{ matrix.package.name }} v${{ matrix.package.version }}"
54 | git push origin "$TAG"
55 |
56 | - name: Generate changelog
57 | id: changelog
58 | uses: mikepenz/release-changelog-builder-action@v6
59 | with:
60 | includeOnlyPaths: |
61 | ${{ matrix.package.path }}/
62 | configurationJson: |
63 | {
64 | "tag_resolver": {
65 | "method": "semver",
66 | "filter": {
67 | "pattern": "^(?!${{ matrix.package.short_name }}/v.+$).*"
68 | },
69 | "transformer": {
70 | "pattern": "^${{ matrix.package.short_name }}/(.+)",
71 | "target": "$1"
72 | }
73 | }
74 | }
75 | env:
76 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
77 |
78 | - name: Create GitHub Release
79 | uses: softprops/action-gh-release@v2
80 | with:
81 | tag_name: ${{ matrix.package.short_name }}/v${{ matrix.package.version }}
82 | name: ${{ matrix.package.short_name }} v${{ matrix.package.version }}
83 | body: |
84 | ${{ steps.changelog.outputs.changelog }}
85 |
86 | ---
87 | 📦 **JSR Package**: https://jsr.io/${{ matrix.package.name }}/${{ matrix.package.version }}
88 | 👤 **Contributors**: ${{ steps.changelog.outputs.contributors }}
89 | draft: false
90 | prerelease: ${{ contains(matrix.package.version, '-') }}
91 | env:
92 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
93 |
94 | # Call publish workflow directly because GitHub doesn't trigger workflows
95 | # from events (like release creation) made with GITHUB_TOKEN.
96 | # See: https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow
97 | publish:
98 | needs: release
99 | uses: ./.github/workflows/publish.yml
100 | permissions:
101 | contents: read
102 | id-token: write # The OIDC ID token is used for authentication with JSR.
103 |
--------------------------------------------------------------------------------
/packages/agent/tests/completer.test.ts:
--------------------------------------------------------------------------------
1 | import { assertEquals, assertRejects } from "@std/assert";
2 | import { Completer } from "../src/utils/mod.ts";
3 |
4 | Deno.test(
5 | "completer resolves with correct value",
6 | async (_t) => {
7 | const completer = new Completer();
8 | setTimeout(() => completer.resolve(true), 10); // Simulate async resolve
9 | const result = await completer.wait({});
10 | assertEquals(result, true);
11 | },
12 | );
13 |
14 | Deno.test(
15 | "completer rejects with provided error",
16 | async (_t) => {
17 | const completer = new Completer();
18 | completer.reject(new Error("Test error"));
19 | await assertRejects(
20 | () => completer.wait({}),
21 | Error,
22 | "Test error",
23 | );
24 | },
25 | );
26 |
27 | Deno.test(
28 | "completer aborts when abort signal is triggered",
29 | async (_t) => {
30 | const completer = new Completer();
31 | const signal = AbortSignal.timeout(100); // Use a short timeout for the test
32 | await assertRejects(
33 | () => completer.wait({ signal }),
34 | Error,
35 | "Operation aborted",
36 | );
37 | },
38 | );
39 |
40 | Deno.test("completer only honors first resolution", async () => {
41 | const completer = new Completer();
42 | completer.resolve(1);
43 | completer.resolve(2); // This should have no effect
44 | const result = await completer.wait({});
45 | assertEquals(result, 1);
46 | });
47 |
48 | Deno.test("completer ignores resolution after rejection", async () => {
49 | const completer = new Completer();
50 | completer.reject(new Error("fail"));
51 | completer.resolve(1); // This should have no effect
52 | await assertRejects(() => completer.wait({}), Error, "fail");
53 | });
54 |
55 | Deno.test("completer ignores abort signal after resolution", async () => {
56 | const completer = new Completer();
57 | const controller = new AbortController();
58 | completer.resolve(1);
59 | controller.abort(); // This should have no effect
60 | const result = await completer.wait({ signal: controller.signal });
61 | assertEquals(result, 1);
62 | });
63 |
64 | Deno.test("completer rejects immediately with pre-aborted signal", async () => {
65 | const completer = new Completer();
66 | const controller = new AbortController();
67 | controller.abort();
68 | await assertRejects(
69 | () => completer.wait({ signal: controller.signal }),
70 | Error,
71 | "Operation aborted",
72 | );
73 | });
74 |
75 | Deno.test("completer delivers same resolution to multiple waiters", async () => {
76 | const completer = new Completer();
77 | const p1 = completer.wait({});
78 | const p2 = completer.wait({});
79 | completer.resolve(42);
80 | assertEquals(await p1, 42);
81 | assertEquals(await p2, 42);
82 | });
83 |
84 | Deno.test("completer only honors first rejection", async () => {
85 | const completer = new Completer();
86 | completer.reject(new Error("first error"));
87 | completer.reject(new Error("second error"));
88 | await assertRejects(() => completer.wait({}), Error, "first error");
89 | });
90 |
91 | Deno.test("completer maintains state for late waiters after resolution", async () => {
92 | const completer = new Completer();
93 | completer.resolve(42);
94 | // Call wait() after it's already been resolved
95 | const result = await completer.wait({});
96 | assertEquals(result, 42);
97 | });
98 |
99 | Deno.test("completer maintains state for late waiters after rejection", async () => {
100 | const completer = new Completer();
101 | completer.reject(new Error("already rejected"));
102 | // Call wait() after it's already been rejected
103 | await assertRejects(
104 | () => completer.wait({}),
105 | Error,
106 | "already rejected",
107 | );
108 | });
109 |
110 | Deno.test("completer maintains same promise identity across wait calls", () => {
111 | const completer = new Completer();
112 | const p1 = completer.wait({});
113 | const p2 = completer.wait({});
114 | // The promises returned by wait() should be the same object
115 | assertEquals(p1, p2);
116 | });
117 |
--------------------------------------------------------------------------------
/packages/agent/src/loopInterceptors/MaxTokensInterceptor.ts:
--------------------------------------------------------------------------------
1 | import type { Message } from "../message.ts";
2 | import {
3 | type InterceptorContext,
4 | type InterceptorResult,
5 | LoopDecision,
6 | type LoopInterceptor,
7 | } from "./interface.ts";
8 |
9 | export interface MaxTokensInterceptorOptions {
10 | enabled?: boolean;
11 | continueMessage?: string;
12 | maxContinuations?: number;
13 | }
14 |
15 | /**
16 | * Loop interceptor that handles automatic continuation when max tokens are reached.
17 | * When the LLM response is truncated due to max tokens, this interceptor can
18 | * automatically continue the conversation.
19 | */
20 | export class MaxTokensInterceptor implements LoopInterceptor {
21 | readonly name = "max-tokens";
22 | readonly description =
23 | "Automatically continues conversation when max tokens are reached";
24 | readonly #defaultContinueMessage = "Continue";
25 |
26 | #options: MaxTokensInterceptorOptions = {};
27 |
28 | constructor(
29 | options: MaxTokensInterceptorOptions = {},
30 | ) {
31 | this.#options = options;
32 | }
33 |
34 | intercept(context: InterceptorContext): Promise {
35 | // Check if this interceptor should run
36 | const enabled = this.#options.enabled ?? true;
37 | if (!enabled || context.stopReason !== "max_tokens") {
38 | return Promise.resolve({ decision: LoopDecision.COMPLETE });
39 | }
40 |
41 | // Check if we've already continued too many times
42 | if (this.#options.maxContinuations !== undefined) {
43 | const continueCount = this.#countContinueMessages(context.messages);
44 | if (continueCount >= this.#options.maxContinuations) {
45 | return Promise.resolve({
46 | decision: LoopDecision.COMPLETE,
47 | reasoning:
48 | `Reached maximum continuations (${this.#options.maxContinuations})`,
49 | });
50 | }
51 | }
52 |
53 | const continueMessage = this.#options.continueMessage ??
54 | this.#defaultContinueMessage;
55 |
56 | // Add continue message to context
57 | context.messages.push({
58 | role: "user",
59 | content: [{
60 | type: "text",
61 | text: continueMessage,
62 | }],
63 | timestamp: new Date(),
64 | });
65 |
66 | return Promise.resolve({
67 | decision: LoopDecision.CONTINUE,
68 | reasoning: "Response was truncated due to max tokens, continuing",
69 | });
70 | }
71 |
72 | /**
73 | * Count how many "Continue" messages are in the recent conversation
74 | * This helps prevent infinite continuation loops
75 | */
76 | #countContinueMessages(messages: Message[]): number {
77 | // Look at the last 10 messages to count recent continuations
78 | const recentMessages = messages.slice(-10);
79 | const continueMessage = this.#options.continueMessage ??
80 | this.#defaultContinueMessage;
81 |
82 | return recentMessages.filter((msg) =>
83 | msg.role === "user" &&
84 | msg.content.some((block) =>
85 | block.type === "text" &&
86 | block.text.trim().toLowerCase() === continueMessage.toLowerCase()
87 | )
88 | ).length;
89 | }
90 |
91 | /**
92 | * Enable or disable max tokens continuation
93 | */
94 | set enabled(value: boolean) {
95 | this.#options.enabled = value;
96 | }
97 |
98 | /**
99 | * Check if max tokens continuation is enabled
100 | */
101 | get enabled(): boolean {
102 | return this.#options.enabled ?? true;
103 | }
104 |
105 | /**
106 | * Set custom continue message
107 | */
108 | set continueMessage(message: string) {
109 | this.#options.continueMessage = message;
110 | }
111 |
112 | /**
113 | * Get the current continue message
114 | */
115 | get continueMessage(): string {
116 | return this.#options.continueMessage ?? this.#defaultContinueMessage;
117 | }
118 |
119 | /**
120 | * Set maximum number of continuations allowed
121 | */
122 | set maxContinuations(max: number | undefined) {
123 | this.#options.maxContinuations = max;
124 | }
125 |
126 | /**
127 | * Get maximum number of continuations allowed
128 | */
129 | get maxContinuations(): number | undefined {
130 | return this.#options.maxContinuations;
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/packages/cli/src/main.ts:
--------------------------------------------------------------------------------
1 | import "@std/dotenv/load";
2 | import {
3 | AnthropicModelProvider,
4 | createZypherAgent,
5 | formatError,
6 | OpenAIModelProvider,
7 | } from "@zypher/agent";
8 | import {
9 | createFileSystemTools,
10 | createImageTools,
11 | RunTerminalCmdTool,
12 | } from "@zypher/agent/tools";
13 | import { Command, EnumType } from "@cliffy/command";
14 | import chalk from "chalk";
15 | import { runAgentInTerminal } from "./runAgentInTerminal.ts";
16 |
17 | const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514";
18 | const DEFAULT_OPENAI_MODEL = "gpt-4o-2024-11-20";
19 |
20 | const providerType = new EnumType(["anthropic", "openai"]);
21 |
22 | function inferProvider(
23 | provider?: string,
24 | model?: string,
25 | ): "anthropic" | "openai" {
26 | const p = provider?.toLowerCase();
27 | if (p === "openai" || p === "anthropic") return p;
28 | if (!model) return "anthropic";
29 | const m = model.toLowerCase();
30 | if (
31 | m.includes("claude") || m.startsWith("sonnet") || m.startsWith("haiku") ||
32 | m.startsWith("opus")
33 | ) {
34 | return "anthropic";
35 | }
36 | return "openai"; // fallback to OpenAI-compatible models
37 | }
38 |
39 | export async function main(): Promise {
40 | // Parse command line arguments using Cliffy
41 | const { options: cli } = await new Command()
42 | .name("zypher")
43 | .description("Zypher Agent CLI")
44 | .type("provider", providerType)
45 | .option("-k, --api-key ", "Model provider API key", {
46 | required: true,
47 | })
48 | .option("-m, --model ", "Model name")
49 | .option(
50 | "-p, --provider ",
51 | "Model provider",
52 | )
53 | .option("-b, --base-url ", "Custom API base URL")
54 | .option(
55 | "-w, --workDir ",
56 | "Working directory for agent operations",
57 | )
58 | .option("-u, --user-id ", "Custom user ID")
59 | .option(
60 | "--openai-api-key ",
61 | "OpenAI API key for image tools when provider=anthropic (ignored if provider=openai)",
62 | )
63 | .parse(Deno.args);
64 |
65 | try {
66 | // Log CLI configuration
67 | if (cli.userId) {
68 | console.log(`👤 Using user ID: ${cli.userId}`);
69 | }
70 |
71 | if (cli.baseUrl) {
72 | console.log(`🌐 Using API base URL: ${cli.baseUrl}`);
73 | }
74 |
75 | if (cli.workDir) {
76 | console.log(`💻 Using working directory: ${cli.workDir}`);
77 | }
78 |
79 | const selectedProvider = inferProvider(cli.provider, cli.model);
80 | console.log(`🤖 Using provider: ${chalk.magenta(selectedProvider)}`);
81 |
82 | const modelToUse = cli.model ??
83 | (selectedProvider === "openai"
84 | ? DEFAULT_OPENAI_MODEL
85 | : DEFAULT_ANTHROPIC_MODEL);
86 | console.log(`🧠 Using model: ${chalk.cyan(modelToUse)}`);
87 |
88 | // Initialize the agent with provided options
89 | const providerInstance = selectedProvider === "openai"
90 | ? new OpenAIModelProvider({
91 | apiKey: cli.apiKey,
92 | baseUrl: cli.baseUrl,
93 | })
94 | : new AnthropicModelProvider({
95 | apiKey: cli.apiKey,
96 | baseUrl: cli.baseUrl,
97 | });
98 |
99 | // Build tools list
100 | const openaiApiKey = cli.provider === "openai"
101 | ? cli.apiKey
102 | : cli.openaiApiKey;
103 |
104 | const tools = [
105 | ...createFileSystemTools(),
106 | RunTerminalCmdTool,
107 | ...(openaiApiKey ? createImageTools(openaiApiKey) : []),
108 | ];
109 |
110 | const agent = await createZypherAgent({
111 | modelProvider: providerInstance,
112 | workingDirectory: cli.workDir,
113 | tools,
114 | context: { userId: cli.userId },
115 | });
116 |
117 | console.log(
118 | "🔧 Registered tools:",
119 | Array.from(agent.mcp.tools.keys()).join(", "),
120 | );
121 |
122 | // Handle Ctrl+C
123 | Deno.addSignalListener("SIGINT", () => {
124 | console.log("\n\nGoodbye! 👋\n");
125 | Deno.exit(0);
126 | });
127 |
128 | await runAgentInTerminal(agent, modelToUse);
129 | } catch (error) {
130 | console.error("Fatal Error:", formatError(error));
131 | Deno.exit(1);
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/packages/agent/src/TaskEvents.ts:
--------------------------------------------------------------------------------
1 | import type { FinalMessage, TokenUsage } from "./llm/ModelProvider.ts";
2 | import type { Message } from "./message.ts";
3 | import type { ToolResult } from "./tools/mod.ts";
4 |
5 | export type TaskEvent =
6 | | TaskTextEvent
7 | | TaskMessageEvent
8 | | TaskHistoryChangedEvent
9 | | TaskToolUseEvent
10 | | TaskToolUseInputEvent
11 | | TaskToolUsePendingApprovalEvent
12 | | TaskToolUseRejectedEvent
13 | | TaskToolUseApprovedEvent
14 | | TaskToolUseResultEvent
15 | | TaskToolUseErrorEvent
16 | | TaskCancelledEvent
17 | | TaskUsageEvent
18 | | TaskCompletedEvent;
19 |
20 | /**
21 | * Event for streaming incremental content updates
22 | */
23 | export interface TaskTextEvent {
24 | type: "text";
25 | content: string;
26 | }
27 |
28 | /**
29 | * Event emitted when a complete message is added to the chat history.
30 | * This includes both new messages from the LLM (assembled from multiple TaskTextEvent updates)
31 | * and new messages added by interceptors (e.g., tool results, continuation prompts).
32 | */
33 | export interface TaskMessageEvent {
34 | type: "message";
35 | message: Message | FinalMessage;
36 | }
37 |
38 | /**
39 | * Event for when existing chat history (previous messages) is modified.
40 | * This event is NOT emitted for adding new messages - only for changes to
41 | * existing message history such as:
42 | * - Editing/replacing existing messages
43 | * - Removing messages (pop, shift, splice)
44 | * - Inserting messages in the middle of history (unshift, splice)
45 | * - Reordering or batch modifications
46 | */
47 | export interface TaskHistoryChangedEvent {
48 | type: "history_changed";
49 | }
50 |
51 | /**
52 | * Event emitted when the LLM indicates intent to call a tool,
53 | * but before the tool input parameters are generated/streamed
54 | */
55 | export interface TaskToolUseEvent {
56 | type: "tool_use";
57 | toolUseId: string;
58 | toolName: string;
59 | }
60 |
61 | /**
62 | * Event emitted when partial tool input is being streamed
63 | */
64 | export interface TaskToolUseInputEvent {
65 | type: "tool_use_input";
66 | toolUseId: string;
67 | toolName: string;
68 | partialInput: string;
69 | }
70 |
71 | /**
72 | * Event emitted when a tool execution requires user approval
73 | */
74 | export interface TaskToolUsePendingApprovalEvent {
75 | type: "tool_use_pending_approval";
76 | toolUseId: string;
77 | toolName: string;
78 | input: unknown;
79 | }
80 |
81 | /**
82 | * Event emitted when a tool execution is rejected by the user
83 | */
84 | export interface TaskToolUseRejectedEvent {
85 | type: "tool_use_rejected";
86 | toolUseId: string;
87 | toolName: string;
88 | reason: string;
89 | }
90 |
91 | /**
92 | * Event emitted when a tool execution is approved by the user
93 | */
94 | export interface TaskToolUseApprovedEvent {
95 | type: "tool_use_approved";
96 | toolUseId: string;
97 | toolName: string;
98 | }
99 |
100 | /**
101 | * Event emitted when a tool execution completes successfully
102 | */
103 | export interface TaskToolUseResultEvent {
104 | type: "tool_use_result";
105 | toolUseId: string;
106 | toolName: string;
107 | input: unknown;
108 | result: ToolResult;
109 | }
110 |
111 | /**
112 | * Event emitted when a tool execution fails with an error
113 | */
114 | export interface TaskToolUseErrorEvent {
115 | type: "tool_use_error";
116 | toolUseId: string;
117 | toolName: string;
118 | input: unknown;
119 | error: unknown;
120 | }
121 |
122 | /**
123 | * Event emitted when a task is cancelled by user or timeout
124 | */
125 | export interface TaskCancelledEvent {
126 | type: "cancelled";
127 | reason: "user" | "timeout";
128 | }
129 |
130 | /**
131 | * Event emitted after each LLM response with token usage information
132 | */
133 | export interface TaskUsageEvent {
134 | type: "usage";
135 | /** Token usage for the current LLM call */
136 | usage: TokenUsage;
137 | /** Cumulative token usage across all LLM calls in this task */
138 | cumulativeUsage: TokenUsage;
139 | }
140 |
141 | /**
142 | * Event emitted when a task completes successfully
143 | */
144 | export interface TaskCompletedEvent {
145 | type: "completed";
146 | /** Total token usage for the entire task (undefined if provider didn't return usage data) */
147 | totalUsage?: TokenUsage;
148 | }
149 |
--------------------------------------------------------------------------------
/packages/agent/src/tools/codeExecutor/worker.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Web Worker entrypoint that executes user-provided TypeScript/JavaScript code in an isolated context.
3 | *
4 | * Receives code via postMessage, executes it with access to a tools proxy, and returns
5 | * the result along with captured console output. Tool calls are forwarded to the host.
6 | */
7 |
8 | ///
9 |
10 | import { Completer } from "../../utils/mod.ts";
11 | import type { ToolResult } from "../mod.ts";
12 | import { encodeBase64 } from "@std/encoding/base64";
13 | import { HostToWorkerMessageSchema } from "./protocol.ts";
14 | import type { CodeExecutionResult, ToolUseMessage } from "./protocol.ts";
15 |
16 | type ToolsProxy = Record Promise>;
17 |
18 | function callTool(
19 | toolName: string,
20 | input: unknown,
21 | ): Promise {
22 | const toolUseId = `ptc_${crypto.randomUUID()}`;
23 | const completer = new Completer();
24 | pendingToolCalls.set(toolUseId, completer);
25 | postMessage({
26 | type: "tool_use",
27 | toolUseId,
28 | toolName,
29 | input,
30 | });
31 | return completer.wait();
32 | }
33 |
34 | function buildToolsProxy(tools: string[]): ToolsProxy {
35 | return Object.fromEntries(
36 | tools.map((t) => [
37 | t,
38 | (input: unknown): Promise => callTool(t, input),
39 | ]),
40 | );
41 | }
42 |
43 | async function executeCode(code: string, tools: ToolsProxy): Promise {
44 | const moduleCode = `export default async function(tools) {\n${code}\n}`;
45 | const dataUrl = `data:application/typescript;base64,${
46 | encodeBase64(
47 | new TextEncoder().encode(moduleCode),
48 | )
49 | }`;
50 | return (await import(dataUrl)).default(tools);
51 | }
52 |
53 | function postMessage(message: ToolUseMessage | CodeExecutionResult) {
54 | self.postMessage(message);
55 | }
56 |
57 | // ============================================================================
58 | // Code Runner Entry Point
59 | // ============================================================================
60 |
61 | const pendingToolCalls = new Map>();
62 | const logs: string[] = [];
63 |
64 | // setup console logging
65 | const stringify = (v: unknown) =>
66 | typeof v === "object" && v !== null ? JSON.stringify(v) : String(v);
67 | const format = (args: unknown[]) => args.map(stringify).join(" ");
68 | console.log = (...args: unknown[]) => logs.push(format(args));
69 | console.info = (...args: unknown[]) => logs.push(`[INFO] ${format(args)}`);
70 | console.debug = (...args: unknown[]) => logs.push(`[DEBUG] ${format(args)}`);
71 | console.warn = (...args: unknown[]) => logs.push(`[WARN] ${format(args)}`);
72 | console.error = (...args: unknown[]) => logs.push(`[ERROR] ${format(args)}`);
73 |
74 | self.onmessage = async (e) => {
75 | const parsed = HostToWorkerMessageSchema.parse(e.data);
76 | switch (parsed.type) {
77 | case "execute":
78 | try {
79 | const result = await executeCode(
80 | parsed.code,
81 | buildToolsProxy(parsed.tools),
82 | );
83 | postMessage({
84 | type: "code_execution_result",
85 | success: true,
86 | data: result,
87 | logs,
88 | });
89 | } catch (error) {
90 | postMessage({
91 | type: "code_execution_result",
92 | success: false,
93 | error,
94 | logs,
95 | });
96 | }
97 | break;
98 |
99 | case "tool_response": {
100 | const pendingCompleter = pendingToolCalls.get(parsed.toolUseId);
101 | if (!pendingCompleter) return;
102 | const result = parsed.result;
103 | pendingToolCalls.delete(parsed.toolUseId);
104 | if (typeof result === "string") {
105 | pendingCompleter.resolve(result);
106 | } else {
107 | result.isError
108 | ? pendingCompleter.reject(new Error(JSON.stringify(result.content)))
109 | : pendingCompleter.resolve(result);
110 | }
111 | break;
112 | }
113 |
114 | case "tool_error": {
115 | const pendingCompleter = pendingToolCalls.get(parsed.toolUseId);
116 | if (!pendingCompleter) return;
117 | pendingToolCalls.delete(parsed.toolUseId);
118 | pendingCompleter.reject(parsed.error);
119 | break;
120 | }
121 | }
122 | };
123 |
--------------------------------------------------------------------------------
/packages/agent/src/storage/StorageService.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Represents attachment metadata for files stored in remote storage
3 | */
4 | export interface AttachmentMetadata {
5 | /** Original filename provided by user */
6 | filename: string;
7 | /** MIME type of the attachment */
8 | contentType: string;
9 | /** Size of the file in bytes */
10 | size?: number;
11 | /** Timestamp when the file was uploaded */
12 | uploadedAt: Date;
13 | /** Any additional metadata that might be storage-provider specific */
14 | additionalMetadata?: Record;
15 | }
16 |
17 | /**
18 | * Result of a successful file upload
19 | */
20 | export interface UploadResult {
21 | /** Unique identifier for the file within the storage system */
22 | id: string;
23 | /** URL that can be used to access the file */
24 | url: string;
25 | /** When the URL will expire, if applicable */
26 | expiresAt?: Date;
27 | /** Metadata about the uploaded file */
28 | metadata: AttachmentMetadata;
29 | }
30 |
31 | /**
32 | * Options for uploading a file
33 | */
34 | export interface UploadOptions {
35 | /** Content type (MIME type) of the file */
36 | contentType: string;
37 | /** Original filename */
38 | filename: string;
39 | /** Size of the file in bytes */
40 | size?: number;
41 | /** How long the URL should be valid for (in seconds) */
42 | urlExpirySeconds?: number;
43 | /** Additional metadata to store with the file */
44 | metadata?: Record;
45 | }
46 |
47 | /**
48 | * Result of generating a pre-signed upload URL
49 | */
50 | export interface GenerateUploadUrlResult {
51 | /** The pre-signed URL for uploading */
52 | url: string;
53 | /** The file ID that will be assigned to the uploaded file */
54 | fileId: string;
55 | }
56 |
57 | /**
58 | * Abstract interface for file storage services
59 | */
60 | export interface StorageService {
61 | /**
62 | * Upload file data to storage using streams
63 | * @param data ReadableStream containing the file data
64 | * @param options Upload options
65 | * @returns Promise resolving to upload result with access URL
66 | */
67 | uploadFile(
68 | data: ReadableStream,
69 | options: UploadOptions,
70 | ): Promise;
71 |
72 | /**
73 | * Upload file data from a buffer (for backward compatibility and small files)
74 | * @param buffer The file data as a Buffer or Uint8Array
75 | * @param options Upload options
76 | * @returns Promise resolving to upload result with access URL
77 | */
78 | uploadFromBuffer(
79 | buffer: Uint8Array,
80 | options: UploadOptions,
81 | ): Promise;
82 |
83 | /**
84 | * Download a file from storage
85 | * @param fileId ID of the file to download
86 | * @param destinationPath Path to save the downloaded file. The directory and file will be created if it doesn't exist.
87 | * @throws {FileNotFoundError} When the requested file does not exist or expired
88 | * @returns Promise that resolves when the file has been successfully downloaded
89 | */
90 | downloadFile(fileId: string, destinationPath: string): Promise;
91 |
92 | /**
93 | * Generate a pre-signed URL for accessing a previously uploaded file
94 | * @param fileId ID of the file to generate URL for
95 | * @param expirySeconds How long the URL should be valid (in seconds)
96 | * @returns Promise resolving to a pre-signed URL that is publicly accessible from the **internet** for the specified duration.
97 | */
98 | getSignedUrl(fileId: string, expirySeconds?: number): Promise;
99 |
100 | /**
101 | * Generate a pre-signed URL for directly uploading a file to storage
102 | * @param options Upload options
103 | * @returns Promise resolving to a pre-signed URL and file ID that can be used to upload and later reference the file
104 | */
105 | generateUploadUrl(options: UploadOptions): Promise;
106 |
107 | /**
108 | * Get metadata for a file
109 | * @param fileId ID of the file to get metadata for
110 | * @returns Promise resolving to file metadata, or null if the file ID does not exist
111 | */
112 | getFileMetadata(fileId: string): Promise;
113 |
114 | /**
115 | * Delete a file from storage
116 | * @param fileId ID of the file to delete
117 | * @returns Promise resolving when deletion is complete
118 | */
119 | deleteFile(fileId: string): Promise;
120 |
121 | /**
122 | * Check if a file exists in storage
123 | * @param fileId ID of the file to check
124 | * @returns Promise resolving to boolean indicating if file exists
125 | */
126 | fileExists(fileId: string): Promise;
127 | }
128 |
--------------------------------------------------------------------------------
/packages/agent/src/loopInterceptors/ToolExecutionInterceptor.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | ImageBlock,
3 | TextBlock,
4 | ToolResultBlock,
5 | ToolUseBlock,
6 | } from "../message.ts";
7 | import type { McpServerManager } from "../mcp/McpServerManager.ts";
8 | import {
9 | type InterceptorContext,
10 | type InterceptorResult,
11 | LoopDecision,
12 | type LoopInterceptor,
13 | } from "./interface.ts";
14 | import { formatError } from "../error.ts";
15 |
16 | /**
17 | * Interceptor that handles tool execution when the LLM requests tool calls
18 | */
19 | export class ToolExecutionInterceptor implements LoopInterceptor {
20 | readonly name = "tool-execution";
21 | readonly description = "Executes tool calls requested by the LLM";
22 |
23 | readonly #mcpServerManager: McpServerManager;
24 |
25 | constructor(mcpServerManager: McpServerManager) {
26 | this.#mcpServerManager = mcpServerManager;
27 | }
28 |
29 | async #executeToolCall(
30 | name: string,
31 | toolUseId: string,
32 | input: unknown,
33 | options?: {
34 | signal?: AbortSignal;
35 | },
36 | ): Promise {
37 | try {
38 | const result = await this.#mcpServerManager.callTool(
39 | toolUseId,
40 | name,
41 | input,
42 | { signal: options?.signal },
43 | );
44 |
45 | if (typeof result === "string") {
46 | return {
47 | type: "tool_result" as const,
48 | toolUseId,
49 | name,
50 | input,
51 | success: true,
52 | content: [
53 | { type: "text", text: result },
54 | ],
55 | };
56 | } else if (result.structuredContent) {
57 | return {
58 | type: "tool_result" as const,
59 | toolUseId,
60 | name,
61 | input,
62 | success: !result.isError,
63 | content: [
64 | { type: "text", text: JSON.stringify(result.structuredContent) },
65 | ],
66 | };
67 | } else {
68 | return {
69 | type: "tool_result" as const,
70 | toolUseId,
71 | name,
72 | input,
73 | success: !result.isError,
74 | content: result.content.map((c): TextBlock | ImageBlock => {
75 | if (c.type === "text") {
76 | return {
77 | type: "text",
78 | text: c.text,
79 | };
80 | } else if (c.type === "image") {
81 | return {
82 | type: "image",
83 | source: {
84 | data: c.data,
85 | mediaType: c.mimeType,
86 | type: "base64",
87 | },
88 | };
89 | } else {
90 | return {
91 | type: "text",
92 | text: JSON.stringify(c),
93 | };
94 | }
95 | }),
96 | };
97 | }
98 | } catch (error) {
99 | console.error(`Error executing tool ${name}:`, error);
100 | return {
101 | type: "tool_result" as const,
102 | toolUseId,
103 | name,
104 | input,
105 | success: false,
106 | content: [{
107 | type: "text",
108 | text: `Error executing tool ${name}: ${formatError(error)}`,
109 | }],
110 | };
111 | }
112 | }
113 |
114 | async intercept(context: InterceptorContext): Promise {
115 | // Check if there are any tool calls in the latest assistant message
116 | const lastMessage = context.messages[context.messages.length - 1];
117 | if (!lastMessage || lastMessage.role !== "assistant") {
118 | return { decision: LoopDecision.COMPLETE };
119 | }
120 |
121 | const toolBlocks = lastMessage.content.filter((
122 | block,
123 | ): block is ToolUseBlock => block.type === "tool_use");
124 | if (toolBlocks.length === 0) {
125 | return { decision: LoopDecision.COMPLETE };
126 | }
127 |
128 | const toolResults = await Promise.all(
129 | toolBlocks.map(async (block) => {
130 | const input = block.input ?? {};
131 | return await this.#executeToolCall(
132 | block.name,
133 | block.toolUseId,
134 | input,
135 | {
136 | signal: context.signal,
137 | },
138 | );
139 | }),
140 | );
141 |
142 | context.messages.push({
143 | role: "user",
144 | content: toolResults,
145 | timestamp: new Date(),
146 | });
147 |
148 | return {
149 | decision: LoopDecision.CONTINUE,
150 | reasoning: `Executed ${toolBlocks.length} tool call(s)`,
151 | };
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/packages/agent/tests/codeExecutor/worker.test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Worker tests - Tests the worker by communicating with it directly
3 | * via postMessage/onmessage to verify tool proxy behavior and code execution.
4 | */
5 |
6 | import { assertEquals } from "@std/assert";
7 | import { Completer } from "@zypher/utils/mod.ts";
8 | import type { CodeExecutionResult } from "@zypher/tools/codeExecutor/protocol.ts";
9 |
10 | Deno.test("worker - builds tools proxy correctly", async () => {
11 | const toolCalls: Array<{ toolName: string; input: unknown }> = [];
12 | const completer = new Completer();
13 |
14 | const worker = new Worker(
15 | new URL("../../src/tools/codeExecutor/worker.ts", import.meta.url),
16 | {
17 | type: "module",
18 | },
19 | );
20 |
21 | try {
22 | worker.onmessage = (event) => {
23 | const data = event.data;
24 |
25 | if (data.type === "tool_use") {
26 | toolCalls.push({ toolName: data.toolName, input: data.input });
27 | // Respond with a successful tool result
28 | worker.postMessage({
29 | type: "tool_response",
30 | toolUseId: data.toolUseId,
31 | toolName: data.toolName,
32 | result: { content: [{ type: "text", text: "ok" }] },
33 | });
34 | } else if (data.type === "code_execution_result") {
35 | completer.resolve(data);
36 | }
37 | };
38 |
39 | worker.postMessage({
40 | type: "execute",
41 | code: `
42 | // Access tools with prefixed names
43 | await tools.mcp__server1__tool_a({});
44 | await tools.mcp__server1__tool_b({});
45 | await tools.mcp__server2__tool_c({});
46 | return { callCount: 3 };
47 | `,
48 | tools: [
49 | "mcp__server1__tool_a",
50 | "mcp__server1__tool_b",
51 | "mcp__server2__tool_c",
52 | ],
53 | });
54 |
55 | const result = await completer.wait();
56 |
57 | assertEquals(result.success, true);
58 | assertEquals(result.data, { callCount: 3 });
59 | assertEquals(toolCalls.length, 3);
60 | assertEquals(toolCalls[0].toolName, "mcp__server1__tool_a");
61 | assertEquals(toolCalls[1].toolName, "mcp__server1__tool_b");
62 | assertEquals(toolCalls[2].toolName, "mcp__server2__tool_c");
63 | } finally {
64 | worker.terminate();
65 | }
66 | });
67 |
68 | Deno.test("worker - captures console output", async () => {
69 | const completer = new Completer();
70 |
71 | const worker = new Worker(
72 | new URL("../../src/tools/codeExecutor/worker.ts", import.meta.url),
73 | {
74 | type: "module",
75 | },
76 | );
77 |
78 | try {
79 | worker.onmessage = (event) => {
80 | if (event.data.type === "code_execution_result") {
81 | completer.resolve(event.data);
82 | }
83 | };
84 |
85 | worker.postMessage({
86 | type: "execute",
87 | code: `
88 | console.log("hello");
89 | console.info("info message");
90 | console.debug("debug message");
91 | console.warn("warning");
92 | console.error("error message");
93 | console.log({ foo: "bar" });
94 | return "done";
95 | `,
96 | tools: [],
97 | });
98 |
99 | const result = await completer.wait();
100 |
101 | assertEquals(result.success, true);
102 | assertEquals(result.data, "done");
103 | assertEquals(result.logs, [
104 | "hello",
105 | "[INFO] info message",
106 | "[DEBUG] debug message",
107 | "[WARN] warning",
108 | "[ERROR] error message",
109 | '{"foo":"bar"}',
110 | ]);
111 | } finally {
112 | worker.terminate();
113 | }
114 | });
115 |
116 | Deno.test("worker - handles exceptions and preserves logs", async () => {
117 | const completer = new Completer();
118 |
119 | const worker = new Worker(
120 | new URL("../../src/tools/codeExecutor/worker.ts", import.meta.url),
121 | {
122 | type: "module",
123 | },
124 | );
125 |
126 | try {
127 | worker.onmessage = (event) => {
128 | if (event.data.type === "code_execution_result") {
129 | completer.resolve(event.data);
130 | }
131 | };
132 |
133 | worker.postMessage({
134 | type: "execute",
135 | code: `
136 | console.log("before error");
137 | throw new Error("something went wrong");
138 | console.log("after error");
139 | `,
140 | tools: [],
141 | });
142 |
143 | const result = await completer.wait();
144 |
145 | assertEquals(result.success, false);
146 | assertEquals(result.logs, ["before error"]);
147 | assertEquals((result.error as Error).message, "something went wrong");
148 | } finally {
149 | worker.terminate();
150 | }
151 | });
152 |
--------------------------------------------------------------------------------
/packages/agent/tests/connect.integration.test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Integration tests for MCP transport functions
3 | *
4 | * These tests verify that the transport functions can successfully create
5 | * connections to MCP servers and handle various scenarios including errors
6 | * and cancellation.
7 | *
8 | * CLI Server Tests:
9 | * - Tests connection to real MCP servers via stdio transport
10 | * - Uses @modelcontextprotocol/server-everything for realistic testing
11 | * - Verifies error handling and abort signal support
12 | *
13 | * Remote Server Tests:
14 | * - Tests connection to MCP HTTP servers (when available)
15 | * - Set MCP_TEST_SERVER_URL environment variable to test against real server
16 | * - Example: MCP_TEST_SERVER_URL="http://localhost:8080/mcp" deno task test
17 | * - Includes error handling and abort signal tests
18 | */
19 |
20 | import { afterEach, describe, test } from "@std/testing/bdd";
21 | import { expect } from "@std/expect";
22 | import { Client } from "@modelcontextprotocol/sdk/client/index.js";
23 | import {
24 | connectToCliServer,
25 | connectToRemoteServer,
26 | } from "@zypher/mcp/connect.ts";
27 | import type { McpCommandConfig, McpRemoteConfig } from "@zypher/mcp/mod.ts";
28 |
29 | describe("Transport Integration Tests", () => {
30 | let client: Client;
31 |
32 | afterEach(async () => {
33 | // Clean up client
34 | await client.close();
35 | });
36 |
37 | describe("connectToCliServer", () => {
38 | test("should connect to MCP server successfully", async () => {
39 | client = new Client({
40 | name: "test-client",
41 | version: "1.0.0",
42 | });
43 |
44 | const commandConfig: McpCommandConfig = {
45 | command: "npx",
46 | args: ["-y", "@modelcontextprotocol/server-everything"],
47 | };
48 |
49 | await connectToCliServer(
50 | Deno.cwd(),
51 | client,
52 | commandConfig,
53 | );
54 |
55 | const toolResult = await client.listTools();
56 | expect(toolResult.tools.length).toBeGreaterThan(0);
57 | });
58 |
59 | test("should throw error for nonexistent command", async () => {
60 | client = new Client({
61 | name: "test-client",
62 | version: "1.0.0",
63 | });
64 |
65 | const commandConfig: McpCommandConfig = {
66 | command: "nonexistent-command-that-will-fail",
67 | args: [],
68 | };
69 |
70 | await expect(
71 | connectToCliServer(Deno.cwd(), client, commandConfig),
72 | ).rejects.toThrow();
73 | });
74 |
75 | test("should handle abort signal", async () => {
76 | client = new Client({
77 | name: "test-client",
78 | version: "1.0.0",
79 | });
80 |
81 | const commandConfig: McpCommandConfig = {
82 | command: "sleep",
83 | args: ["10"],
84 | };
85 |
86 | const abortController = new AbortController();
87 |
88 | // Abort after a short delay
89 | setTimeout(() => abortController.abort(), 100);
90 |
91 | await expect(
92 | connectToCliServer(Deno.cwd(), client, commandConfig, {
93 | signal: abortController.signal,
94 | }),
95 | ).rejects.toThrow("abort");
96 | });
97 | });
98 |
99 | describe("connectToRemoteServer", () => {
100 | test("should connect to MCP HTTP server when URL provided", {
101 | ignore: !Deno.env.get("MCP_TEST_SERVER_URL"),
102 | }, async () => {
103 | const testServerUrl = Deno.env.get("MCP_TEST_SERVER_URL")!;
104 |
105 | client = new Client({
106 | name: "test-client",
107 | version: "1.0.0",
108 | });
109 |
110 | const remoteConfig: McpRemoteConfig = {
111 | url: testServerUrl,
112 | };
113 |
114 | await connectToRemoteServer(client, remoteConfig);
115 |
116 | // Verify we can list tools from the connected server
117 | const toolResult = await client.listTools();
118 | expect(toolResult.tools.length).toBeGreaterThan(0);
119 | });
120 |
121 | test("should throw error for invalid URL", async () => {
122 | client = new Client({
123 | name: "test-client",
124 | version: "1.0.0",
125 | });
126 |
127 | const remoteConfig: McpRemoteConfig = {
128 | url: "http://localhost:9999/nonexistent-server",
129 | };
130 |
131 | await expect(
132 | connectToRemoteServer(client, remoteConfig),
133 | //match any error message that contains both "error" and "connection" words, regardless of order or case
134 | ).rejects.toThrow(/(?=.*error)(?=.*connection)/i);
135 | });
136 |
137 | test("should handle abort signal", async () => {
138 | client = new Client({
139 | name: "test-client",
140 | version: "1.0.0",
141 | });
142 |
143 | const remoteConfig: McpRemoteConfig = {
144 | url: "http://localhost:9999/mcp",
145 | };
146 |
147 | const abortController = new AbortController();
148 |
149 | // Abort immediately since we don't have a real server
150 | abortController.abort();
151 |
152 | await expect(
153 | connectToRemoteServer(client, remoteConfig, {
154 | signal: abortController.signal,
155 | }),
156 | ).rejects.toThrow("abort");
157 | });
158 | });
159 | });
160 |
--------------------------------------------------------------------------------
/docs/content/docs/core-concepts/tools-and-mcp/programmatic-tool-calling.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Programmatic Tool Calling
3 | description: Enable LLMs to write and execute code that calls tools programmatically, reducing token usage by 85-98%
4 | ---
5 |
6 | # Programmatic Tool Calling
7 |
8 | Programmatic Tool Calling (PTC) enables LLMs to generate TypeScript code that runs in an isolated Deno Worker to orchestrate and execute tool calls. The code processes data internally, and only the final result is shared with the LLM. This approach can significantly reduce token usage for complex tasks that involve many tool calls or large datasets.
9 |
10 | ```
11 | Traditional: Programmatic Tool Calling (PTC):
12 | ┌─────────┐ ┌─────────┐
13 | │ LLM │ │ LLM │
14 | └────┬────┘ └────┬────┘
15 | │ 20 tool calls │ execute_code call
16 | │ 110K tokens returned │ 2K tokens returned
17 | ▼ ▼
18 | ┌─────────────────┐ ┌─────────────────────────────┐
19 | │ Tool Results │ │ Deno Worker (isolated) │
20 | │ (in context) │ │ ┌─────────────────────────┐ │
21 | │ - Employee 1... │ │ │ TypeScript code │ │
22 | │ - Employee 2... │ │ │ calls tools │ │
23 | │ - ... │ │ └─────────────────────────┘ │
24 | │ - Employee 20 │ │ Returns:summary(2K token) │
25 | └─────────────────┘ └─────────────────────────────┘
26 |
27 | ```
28 |
29 | ## The `programmatic()` Function
30 |
31 | In Zypher, programmatic tool calling is implemented with the programmatic function. You can use it to wrap tools or MCP servers so they are callable only via code execution:
32 |
33 | ```typescript
34 | import { programmatic } from '@zypher/agent/tools';
35 | ```
36 |
37 | ### Using Programmatic Tool Calling with Tools
38 |
39 | ```typescript
40 | import { createTool, programmatic } from '@zypher/agent/tools';
41 | import { z } from 'zod';
42 |
43 | const getWeather = createTool({
44 | name: "get_weather",
45 | description: "Get the current weather for a city",
46 | schema: z.object({
47 | city: z.string().describe("City name"),
48 | }),
49 | execute: ({ city }) => {
50 | // Return weather data for the city
51 | },
52 | });
53 |
54 | const agent = await createZypherAgent({
55 | modelProvider: new AnthropicModelProvider({ apiKey }),
56 | tools: [...programmatic(getWeather)],
57 | });
58 | ```
59 | **Note:** Wraped tools can only be called via the `execute_code` tool—the LLM cannot call them directly.
60 |
61 | ### Using Programmatic Tool Calling with MCP Servers
62 |
63 | ```typescript
64 | const agent = await createZypherAgent({
65 | modelProvider: new AnthropicModelProvider({ apiKey }),
66 | mcpServers: [
67 | programmatic({
68 | id: "deepwiki",
69 | displayName: "DeepWiki",
70 | type: "remote",
71 | remote: { url: "https://mcp.deepwiki.com/mcp" },
72 | }),
73 | ],
74 | });
75 | ```
76 |
77 |
78 |
79 | ## How It Works
80 |
81 | 1. LLM calls `execute_code` with TypeScript code
82 | 2. Code runs in an isolated Deno Worker (all permissions disabled)
83 | 3. Worker calls wrapped tools via RPC
84 | 4. Tools return results directly to the worker (not LLM context)
85 | 5. Worker processes data and returns only the final summary
86 |
87 | ## Code Execution Example
88 |
89 | When asked "What's the average temperature across 5 cities?", the LLM writes:
90 |
91 | ```typescript
92 | const cities = ["Paris", "London", "Berlin", "Madrid", "Rome"];
93 | const results = [];
94 |
95 | for (const city of cities) {
96 | const weather = await tools.get_weather({ city });
97 | results.push({ city, temp: weather.temperature });
98 | }
99 |
100 | const avgTemp = results.reduce((sum, r) => sum + r.temp, 0) / results.length;
101 | const warmest = results.sort((a, b) => b.temp - a.temp)[0];
102 |
103 | return {
104 | averageTemperature: avgTemp.toFixed(1),
105 | warmestCity: warmest.city,
106 | };
107 | ```
108 |
109 | The code runs in a Deno Worker, and only the final summary is returned to the LLM — not the raw data for each city.
110 |
111 | ## Security
112 |
113 | **Full isolation:**
114 | - Runs in a separate Deno Worker with all permissions disabled
115 | - No file system, network, or environment access
116 | - Cannot spawn subprocesses
117 |
118 | **Controlled tool access:**
119 | - Tools called via RPC through `postMessage`
120 | - Main thread validates and executes tool calls
121 |
122 | **Timeout protection:**
123 | - Configurable execution timeout (default: 10 minutes)
124 | - `worker.terminate()` forcefully kills runaway code
125 |
126 | ## When to Use
127 |
128 | **Ideal for:**
129 | - Data aggregation across many records
130 | - Batch operations with loops
131 | - Multi-tool workflows with large intermediate results
132 | - Search and filter operations
133 |
134 | **Avoid for:**
135 | - Simple single tool calls
136 | - Small data returns (< 1K tokens)
137 | - When LLM needs all raw data
138 |
139 | ---
140 |
141 | For creating custom tools, see [Built-in Tools](/docs/core-concepts/tools-and-mcp/built-in-tools).
142 |
--------------------------------------------------------------------------------
/packages/agent/src/tools/codeExecutor/ExecuteCodeTool.ts:
--------------------------------------------------------------------------------
1 | import z from "zod";
2 | import type { McpServerManager } from "../../mcp/mod.ts";
3 | import { AbortError, isAbortError } from "../../error.ts";
4 | import { createTool, type Tool, type ToolResult } from "../mod.ts";
5 | import { executeCode } from "./executeCode.ts";
6 |
7 | /**
8 | * Configuration options for the execute_code tool.
9 | */
10 | export interface ExecuteCodeToolOptions {
11 | /**
12 | * Maximum execution time in milliseconds before the code is terminated.
13 | * @default 600000 (10 minutes)
14 | */
15 | timeout?: number;
16 | }
17 |
18 | export function createExecuteCodeTool(
19 | mcpServerManager: McpServerManager,
20 | options: ExecuteCodeToolOptions = {},
21 | ): Tool {
22 | const { timeout = 600_000 } = options;
23 |
24 | return createTool({
25 | name: "execute_code",
26 | description:
27 | `Execute arbitrary TypeScript/JavaScript code in an isolated environment.
28 |
29 | Use this tool for:
30 | - Complex computations, data transformations, or algorithmic tasks
31 | - Tasks requiring loops, conditionals, filtering, or aggregation
32 | - Programmatic Tool Calling (PTC): orchestrating multiple tool calls in code
33 |
34 | ## Programmatic Tool Calling (PTC)
35 |
36 | For tasks requiring many tool calls (loops, filtering, pre/post-processing), write code that orchestrates all operations in a single execution instead of calling tools one-by-one.
37 |
38 | **Why PTC is better:**
39 | - Loops and conditionals are handled in code, not by the LLM
40 | - Multiple tool calls execute in one invocation—no back-and-forth inference cycles
41 | - Intermediate results stay in code scope; only the final answer is returned
42 | - Reduces context window usage and latency significantly
43 |
44 | ### Code Environment
45 | Your code runs as the body of an async function. Write code directly—do NOT wrap it in a function. You have access to a \`tools\` proxy object to call any available tool.
46 |
47 | \`\`\`typescript
48 | // Your code is executed like this internally:
49 | // async function execute(tools) {
50 | //
51 | // }
52 |
53 | // ❌ WRONG - don't define your own function:
54 | async function main() { ... }
55 |
56 | // ✅ CORRECT - write code directly:
57 | const result = await tools.someApi({ param: "value" });
58 | return result;
59 | \`\`\`
60 |
61 | ### Tool Results
62 | Tools return ToolResult objects:
63 | \`\`\`typescript
64 | // {
65 | // content: [{ type: "text", text: "Human-readable output" }],
66 | // structuredContent?: { ... }, // Optional, but if outputSchema is defined, this is required and strictly typed
67 | // }
68 | \`\`\`
69 |
70 | **Tip:** If a tool doesn't have outputSchema defined, inspect its result structure first before writing complex logic:
71 | \`\`\`typescript
72 | const sample = await tools.some_tool({ param: "value" });
73 | return sample; // Examine the output, then write proper code in next execution
74 | \`\`\`
75 |
76 | ### Example
77 | \`\`\`typescript
78 | // Find the largest file (only return what's asked, not all file sizes)
79 | const files = ["config.json", "settings.json", "data.json"];
80 | let largest = { file: "", size: 0 };
81 | for (const file of files) {
82 | const stat = await tools.stat_file({ path: file });
83 | if (stat.structuredContent.size > largest.size) {
84 | largest = { file, size: stat.structuredContent.size };
85 | }
86 | }
87 | // Return the answer the user asked for, not all intermediate file stats
88 | return largest;
89 | \`\`\`
90 |
91 | ## Guidelines
92 | - **IMPORTANT:** When a task involves tools that may return large datasets (e.g., listing all items, fetching many records), use execute_code from the START. Call the data-fetching tool inside your code so the large response stays in code scope and doesn't consume context window. Never call such tools directly first—always wrap them in execute_code.
93 | - **Keep return values concise.** Avoid returning all intermediate data (e.g., all items fetched in a loop). Include important details when necessary, but focus on the specific answer the user asked for.
94 | - Use console.log() for debugging (output is captured), but avoid excessive logging as it adds to context
95 | - Handle errors with try/catch when appropriate
96 | - Timeout: ${timeout / 1000} seconds
97 | `,
98 | schema: z.object({
99 | code: z.string().describe("The code to execute"),
100 | }),
101 | execute: async ({ code }): Promise => {
102 | try {
103 | const result = await executeCode(code, mcpServerManager, {
104 | signal: AbortSignal.timeout(timeout),
105 | });
106 |
107 | const structuredContent = {
108 | data: result.data,
109 | error: result.error,
110 | logs: result.logs,
111 | };
112 |
113 | return {
114 | content: [{ type: "text", text: JSON.stringify(structuredContent) }],
115 | structuredContent,
116 | isError: !result.success,
117 | };
118 | } catch (error) {
119 | if (isAbortError(error)) {
120 | throw new AbortError(
121 | `Code execution timed out after ${timeout / 1000} seconds`,
122 | { cause: error },
123 | );
124 | }
125 | throw error;
126 | }
127 | },
128 | });
129 | }
130 |
--------------------------------------------------------------------------------
/packages/agent/src/loopInterceptors/ErrorDetectionInterceptor.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorDetector } from "./errorDetection/mod.ts";
2 | import { AbortError } from "../error.ts";
3 | import {
4 | type InterceptorContext,
5 | type InterceptorResult,
6 | LoopDecision,
7 | type LoopInterceptor,
8 | } from "./interface.ts";
9 |
10 | /**
11 | * Loop interceptor that manages error detection with customizable error detectors.
12 | * Allows registration of custom error detectors for different languages and tools.
13 | */
14 | export class ErrorDetectionInterceptor implements LoopInterceptor {
15 | readonly name = "error-detection";
16 | readonly description =
17 | "Detects code errors using configurable error detectors";
18 |
19 | #errorDetectors: ErrorDetector[] = [];
20 | #enabled: boolean = true;
21 |
22 | constructor(enabled: boolean = true) {
23 | this.#enabled = enabled;
24 | }
25 |
26 | /**
27 | * Register a custom error detector
28 | * @param detector The error detector to register
29 | */
30 | registerDetector(detector: ErrorDetector): void {
31 | // Check for name conflicts
32 | if (this.#errorDetectors.some((d) => d.name === detector.name)) {
33 | throw new Error(
34 | `Error detector with name '${detector.name}' is already registered`,
35 | );
36 | }
37 |
38 | this.#errorDetectors.push(detector);
39 | }
40 |
41 | /**
42 | * Unregister an error detector by name
43 | * @param name The name of the detector to remove
44 | * @returns boolean True if detector was found and removed
45 | */
46 | unregisterDetector(name: string): boolean {
47 | const index = this.#errorDetectors.findIndex((d) => d.name === name);
48 | if (index >= 0) {
49 | this.#errorDetectors.splice(index, 1);
50 | return true;
51 | }
52 | return false;
53 | }
54 |
55 | /**
56 | * Get list of registered detector names
57 | */
58 | get registeredDetectors(): string[] {
59 | return this.#errorDetectors.map((d) => d.name);
60 | }
61 |
62 | /**
63 | * Clear all registered detectors
64 | */
65 | clearDetectors(): void {
66 | this.#errorDetectors = [];
67 | }
68 |
69 | async intercept(context: InterceptorContext): Promise {
70 | // Check if this interceptor should run
71 | if (!this.#enabled || this.#errorDetectors.length === 0) {
72 | return { decision: LoopDecision.COMPLETE };
73 | }
74 |
75 | const errors = await this.detectErrors(
76 | context.zypherContext.workingDirectory,
77 | { signal: context.signal },
78 | );
79 |
80 | if (errors) {
81 | // Add error message to context
82 | context.messages.push({
83 | role: "user",
84 | content: [{
85 | type: "text",
86 | text: `🔍 Detected code errors that need to be fixed:\n\n${errors}`,
87 | }],
88 | timestamp: new Date(),
89 | });
90 |
91 | return {
92 | decision: LoopDecision.CONTINUE,
93 | reasoning: "Found code errors that need to be addressed",
94 | };
95 | }
96 |
97 | return {
98 | decision: LoopDecision.COMPLETE,
99 | reasoning: "No code errors detected",
100 | };
101 | }
102 |
103 | /**
104 | * Run error detection using registered detectors
105 | * @param workingDirectory The directory to run detection in
106 | * @param options Options including abort signal
107 | * @returns Promise Combined error messages if errors found, null otherwise
108 | */
109 | private async detectErrors(
110 | workingDirectory: string,
111 | options: { signal?: AbortSignal },
112 | ): Promise {
113 | const applicableDetectors = [];
114 |
115 | // Find applicable detectors
116 | for (const detector of this.#errorDetectors) {
117 | if (options.signal?.aborted) {
118 | throw new AbortError("Aborted while checking detectors");
119 | }
120 |
121 | try {
122 | if (await detector.isApplicable(workingDirectory)) {
123 | applicableDetectors.push(detector);
124 | }
125 | } catch (error) {
126 | console.warn(
127 | `Error checking if detector ${detector.name} is applicable:`,
128 | error,
129 | );
130 | }
131 | }
132 |
133 | if (applicableDetectors.length === 0) {
134 | return null;
135 | }
136 |
137 | // Run applicable detectors
138 | const errorMessages = [];
139 |
140 | for (const detector of applicableDetectors) {
141 | if (options.signal?.aborted) {
142 | throw new AbortError("Aborted while running detectors");
143 | }
144 |
145 | try {
146 | const result = await detector.detect(workingDirectory);
147 | if (result) {
148 | errorMessages.push(result);
149 | }
150 | } catch (error) {
151 | console.warn(`Error running detector ${detector.name}:`, error);
152 | }
153 | }
154 |
155 | if (errorMessages.length === 0) {
156 | return null;
157 | }
158 |
159 | return errorMessages.join("\n\n");
160 | }
161 |
162 | /**
163 | * Enable or disable error detection
164 | */
165 | set enabled(value: boolean) {
166 | this.#enabled = value;
167 | }
168 |
169 | /**
170 | * Check if error detection is enabled
171 | */
172 | get enabled(): boolean {
173 | return this.#enabled;
174 | }
175 | }
176 |
--------------------------------------------------------------------------------
/packages/agent/src/message.ts:
--------------------------------------------------------------------------------
1 | export type ContentBlock =
2 | | TextBlock
3 | | ImageBlock
4 | | ToolUseBlock
5 | | ToolResultBlock
6 | | FileAttachment
7 | | ThinkingBlock;
8 |
9 | /**
10 | * Extended message parameter type that includes checkpoint information
11 | */
12 | export interface Message {
13 | content: Array;
14 |
15 | role: "user" | "assistant";
16 |
17 | /**
18 | * Timestamp indicating when the message was created
19 | */
20 | timestamp: Date;
21 |
22 | /**
23 | * Optional reference to a checkpoint created before this message
24 | */
25 | checkpointId?: string;
26 |
27 | /**
28 | * Optional metadata about the checkpoint
29 | */
30 | checkpoint?: {
31 | id: string;
32 | name: string;
33 | timestamp: string;
34 | };
35 | }
36 |
37 | /**
38 | * Regular text content
39 | */
40 | export interface TextBlock {
41 | type: "text";
42 | text: string;
43 | }
44 |
45 | /**
46 | * Image content
47 | */
48 | export interface ImageBlock {
49 | type: "image";
50 | source: Base64ImageSource | UrlImageSource;
51 | }
52 |
53 | /**
54 | * Base64 image source
55 | */
56 | export interface Base64ImageSource {
57 | type: "base64";
58 | /** The base64 encoded image data */
59 | data: string;
60 | /** The MIME type of the image */
61 | mediaType: string;
62 | }
63 |
64 | /**
65 | * URL image source
66 | */
67 | export interface UrlImageSource {
68 | type: "url";
69 | /** The URL of the image */
70 | url: string;
71 | /** The MIME type of the image */
72 | mediaType: string;
73 | }
74 |
75 | export interface ToolUseBlock {
76 | type: "tool_use";
77 | /** The ID of the tool use */
78 | toolUseId: string;
79 | /** The name of the tool the agent requested to use */
80 | name: string;
81 | /** The input parameters for the tool */
82 | input: unknown;
83 | }
84 |
85 | export interface ToolResultBlock {
86 | type: "tool_result";
87 | /** The ID of the tool use */
88 | toolUseId: string;
89 | /** The name of the tool that was used */
90 | name: string;
91 | /** The input parameters for the tool */
92 | input: unknown;
93 | /** Whether the tool execution was successful */
94 | success: boolean;
95 | /** The content of the tool result */
96 | content: (TextBlock | ImageBlock)[];
97 | }
98 |
99 | /**
100 | * File attachment content
101 | */
102 | export interface FileAttachment {
103 | type: "file_attachment";
104 | /** The ID of the file in storage */
105 | fileId: string;
106 | /** The MIME type of the file */
107 | mimeType: string;
108 | }
109 |
110 | /**
111 | * Thinking block content
112 | */
113 | export interface ThinkingBlock {
114 | type: "thinking";
115 | /** An opaque field and should not be interpreted or parsed - it exists solely for verification purposes. */
116 | signature: string;
117 | /** The content of the thinking block */
118 | thinking: string;
119 | }
120 |
121 | /**
122 | * Type guard to validate if an unknown value is a Message object.
123 | * Also handles converting string timestamps to Date objects.
124 | *
125 | * @param value - The value to check
126 | * @returns True if the value is a valid Message object
127 | */
128 | export function isMessage(value: unknown): value is Message {
129 | if (typeof value !== "object" || value === null) {
130 | return false;
131 | }
132 |
133 | const hasRequiredProps = "role" in value && "content" in value &&
134 | "timestamp" in value;
135 |
136 | if (!hasRequiredProps) {
137 | return false;
138 | }
139 |
140 | // Convert timestamp string to Date object if needed
141 | if (typeof (value as Message).timestamp === "string") {
142 | (value as Message).timestamp = new Date((value as Message).timestamp);
143 | }
144 |
145 | return true;
146 | }
147 |
148 | export function isFileAttachment(value: unknown): value is FileAttachment {
149 | return typeof value === "object" && value !== null &&
150 | "type" in value && value.type === "file_attachment" &&
151 | "fileId" in value && typeof value.fileId === "string";
152 | }
153 | /**
154 | * Prints a message from the agent's conversation to the console with proper formatting.
155 | * Handles different types of message blocks including text, tool use, and tool results.
156 | *
157 | * @param {MessageParam} message - The message to print
158 | *
159 | * @example
160 | * printMessage({
161 | * role: 'assistant',
162 | * content: 'Hello, how can I help you?'
163 | * });
164 | *
165 | * printMessage({
166 | * role: 'user',
167 | * content: [{
168 | * type: 'tool_result',
169 | * tool_use_id: '123',
170 | * content: 'Tool execution result'
171 | * }]
172 | * });
173 | */
174 | export function printMessage(message: Message): void {
175 | console.log(`\n🗣️ Role: ${message.role}`);
176 | console.log("════════════════════");
177 |
178 | const content = Array.isArray(message.content)
179 | ? message.content
180 | : [{ type: "text", text: message.content, citations: [] }];
181 |
182 | for (const block of content) {
183 | if (block.type === "text") {
184 | console.log(block.text);
185 | } else if (
186 | block.type === "tool_use" &&
187 | "name" in block &&
188 | "input" in block
189 | ) {
190 | console.log(`🔧 Using tool: ${block.name}`);
191 | console.log("Parameters:", JSON.stringify(block.input, null, 2));
192 | } else if (block.type === "tool_result" && "content" in block) {
193 | console.log("📋 Tool result:");
194 | console.log(block.content);
195 | } else {
196 | console.log("Unknown block type:", block);
197 | }
198 | console.log("---");
199 | }
200 | }
201 |
--------------------------------------------------------------------------------
/docs/content/docs/quick-start.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Quick Start
3 | description: Install Zypher Agent and create your first AI agent in under 5 minutes
4 | ---
5 |
6 | # Quick Start
7 |
8 | Get Zypher Agent running in under 5 minutes. This guide shows you the essential pattern for creating any AI agent.
9 |
10 | ## Prerequisites
11 |
12 | - **Deno 2.0+** ([install here](https://deno.land/manual/getting_started/installation))
13 | - **API Keys** - Get an Anthropic API key ([here](https://console.anthropic.com)) and Firecrawl API key ([here](https://firecrawl.dev))
14 | - *Note: This example uses Anthropic's Claude, but you can use OpenAI or other providers (see [LLM Providers](/docs/core-concepts/llm-providers))*
15 | - *Firecrawl is just an example MCP server for web crawling - you can use any MCP server or none at all*
16 |
17 | ## Step 1: Install Zypher Agent
18 |
19 | Add Zypher Agent to your project:
20 |
21 | ```bash
22 | deno add jsr:@zypher/agent
23 | deno add npm:rxjs-for-await
24 | ```
25 |
26 | ## Step 2: Set Up Environment Variables
27 |
28 | Create a `.env` file in your project root:
29 |
30 | ```bash
31 | ANTHROPIC_API_KEY=your_anthropic_api_key_here
32 | FIRECRAWL_API_KEY=your_firecrawl_api_key_here
33 | ```
34 |
35 | ## Step 3: Create Your First Agent
36 |
37 | Create a new file called `main.ts`:
38 |
39 | *This example demonstrates the core pattern: create an agent with your preferred LLM provider, optionally register MCP servers for external capabilities, then run tasks. We're using Firecrawl as an example MCP server to enable web crawling.*
40 |
41 | ```typescript
42 | import {
43 | AnthropicModelProvider,
44 | createZypherContext,
45 | ZypherAgent,
46 | } from "@zypher/agent";
47 | import { eachValueFrom } from "rxjs-for-await";
48 |
49 | // Helper function to safely get environment variables
50 | function getRequiredEnv(name: string): string {
51 | const value = Deno.env.get(name);
52 | if (!value) {
53 | throw new Error(`Environment variable ${name} is not set`);
54 | }
55 | return value;
56 | }
57 |
58 | // Initialize the agent execution context
59 | const zypherContext = await createZypherContext(Deno.cwd());
60 |
61 | // Create the agent with your preferred LLM provider
62 | const agent = new ZypherAgent(
63 | zypherContext,
64 | new AnthropicModelProvider({
65 | apiKey: getRequiredEnv("ANTHROPIC_API_KEY"),
66 | }),
67 | );
68 |
69 | // Register and connect to an MCP server to give the agent web crawling capabilities
70 | await agent.mcp.registerServer({
71 | id: "firecrawl",
72 | type: "command",
73 | command: {
74 | command: "npx",
75 | args: ["-y", "firecrawl-mcp"],
76 | env: {
77 | FIRECRAWL_API_KEY: getRequiredEnv("FIRECRAWL_API_KEY"),
78 | },
79 | },
80 | });
81 |
82 | // Run a task - the agent will use web crawling to find current AI news
83 | const event$ = agent.runTask(
84 | `Find latest AI news`,
85 | "claude-sonnet-4-20250514",
86 | );
87 |
88 | // Stream the results in real-time
89 | for await (const event of eachValueFrom(event$)) {
90 | console.log(event);
91 | }
92 | ```
93 |
94 | ## Step 4: Run Your Agent
95 |
96 | Execute your agent:
97 |
98 | ```bash
99 | deno run -A main.ts
100 | ```
101 |
102 | You'll see the AI agent respond with an explanation of what AI agents are.
103 |
104 | ## That's it!
105 |
106 | You've created your first Zypher Agent with just a few lines of code. The basic pattern is:
107 |
108 | 1. **Install** - `deno add jsr:@zypher/agent`
109 | 2. **Create** - Agent with your preferred LLM provider
110 | 3. **Run** - Any task with `agent.runTask()`
111 |
112 | ## Try Different Tasks
113 |
114 | Change the task string to try different things:
115 |
116 | ```typescript
117 | // Ask questions
118 | agent.runTask("What are the benefits of using AI agents?")
119 |
120 | // Give instructions
121 | agent.runTask("Write a hello world program in TypeScript")
122 |
123 | // Request analysis
124 | agent.runTask("List the pros and cons of different programming languages")
125 | ```
126 |
127 | ## Interactive CLI Mode
128 |
129 | For a more interactive experience, you can use the built-in terminal interface with `runAgentInTerminal`. This provides a chat-like interface where you can have conversations with your agent:
130 |
131 | ```typescript
132 | import { runAgentInTerminal } from "@zypher/agent";
133 |
134 | // const agent = new ZypherAgent(...)
135 | // await agent.init()
136 |
137 | // Start interactive CLI
138 | await runAgentInTerminal(agent, "claude-sonnet-4-20250514");
139 | ```
140 |
141 | This opens a terminal interface where you can:
142 | - Chat with your agent interactively
143 | - See real-time responses as they stream
144 | - Ask follow-up questions in the same session
145 |
146 | ## Next Steps
147 |
148 | Congratulations! 🎉 You've created your first Zypher Agent. Here's what to explore next:
149 |
150 | ### Learn the Fundamentals
151 | - **[LLM Providers](/docs/core-concepts/llm-providers)** - Configure different AI models
152 | - **[Tools & MCP](/docs/core-concepts/tools-and-mcp)** - Built-in tools and external integrations
153 | - **[Checkpoints](/docs/core-concepts/checkpoints)** - Save and restore agent state
154 |
155 | ### Advanced Features
156 | - **[Loop Interceptors](/docs/core-concepts/loop-interceptors)** - Control execution flow
157 | - **[Built-in Tools](/docs/core-concepts/tools-and-mcp/built-in-tools)** - File system, terminal, and search tools
158 |
159 | ### Get Inspired
160 | - **[Basic Research Agent](/docs/examples/basic-research-agent)** - Complete example with web crawling
161 | - **[Examples Repository](https://github.com/corespeed-io/zypher-examples)** - Ready-to-run examples and starter templates
162 |
163 | ---
164 |
165 | **Ready for more?** Check out our [examples section](/docs/examples) or browse the [examples repository](https://github.com/corespeed-io/zypher-examples) to see what's possible with Zypher Agent!
--------------------------------------------------------------------------------
/packages/agent/src/mcp/utils.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import type {
3 | Argument,
4 | Package,
5 | ServerDetail,
6 | } from "@corespeed/mcp-store-client";
7 | import type { McpServerEndpoint } from "./mod.ts";
8 |
9 | // =============================================================================
10 | // Zod utilities
11 | // =============================================================================
12 |
13 | export function jsonToZod(inputSchema: {
14 | type: "object";
15 | properties?: Record;
16 | required?: string[];
17 | }): z.ZodObject> {
18 | const properties = inputSchema.properties ?? {};
19 | const required = inputSchema.required ?? [];
20 |
21 | const schemaProperties = Object.entries(properties).reduce(
22 | (acc: Record, [key, value]) => {
23 | const property = value as { type: string; description?: string };
24 | const zodType = createZodType(property);
25 | acc[key] = required.includes(key) ? zodType : zodType.optional();
26 | return acc;
27 | },
28 | {} as Record,
29 | );
30 |
31 | return z.object(schemaProperties);
32 | }
33 |
34 | export function createZodType(property: {
35 | type: string;
36 | description?: string;
37 | }): z.ZodTypeAny {
38 | const typeMap: Record z.ZodTypeAny> = {
39 | string: () => z.string(),
40 | number: () => z.number(),
41 | boolean: () => z.boolean(),
42 | array: () => z.array(z.any()),
43 | object: () => z.record(z.string(), z.any()),
44 | };
45 |
46 | const zodType = typeMap[property.type]?.() ?? z.any();
47 | return property.description
48 | ? zodType.describe(property.description)
49 | : zodType;
50 | }
51 |
52 | // =============================================================================
53 | // MCP Store registry utilities
54 | // =============================================================================
55 |
56 | /**
57 | * Convert array of {name, value} objects to a Record, filtering out invalid entries
58 | */
59 | function convertToRecord(
60 | items?: Array<{ name?: string; value?: string }>,
61 | ): Record | undefined {
62 | if (!items?.length) return undefined;
63 |
64 | const entries = items
65 | .filter((item): item is { name: string; value: string } =>
66 | !!item.name && !!item.value
67 | )
68 | .map((item) => [item.name, item.value] as [string, string]);
69 |
70 | return entries.length > 0 ? Object.fromEntries(entries) : undefined;
71 | }
72 |
73 | /**
74 | * Extract string values from an array of {value} objects, filtering out undefined values
75 | */
76 | function extractArguments(
77 | args?: Array,
78 | ): string[] {
79 | if (!args) return [];
80 |
81 | const result: string[] = [];
82 |
83 | for (const arg of args) {
84 | // Skip arguments without a value
85 | if (arg.value === undefined) continue;
86 |
87 | // Handle named arguments: include the name with the value
88 | if (arg.type === "named" && arg.name) {
89 | // Use --name=value format for named arguments
90 | result.push(`--${arg.name}=${arg.value}`);
91 | } else {
92 | // Handle positional arguments: just the value
93 | result.push(arg.value);
94 | }
95 | }
96 |
97 | return result;
98 | }
99 |
100 | /**
101 | * Build command arguments based on the package runtime hint
102 | */
103 | function buildCommandArgs(
104 | pkg: Package,
105 | runtimeArgs: string[],
106 | packageArgs: string[],
107 | ): string[] {
108 | const runtimeHint = pkg.runtimeHint?.toLowerCase();
109 |
110 | // Handle docker runtime specially - needs "run" subcommand
111 | if (runtimeHint === "docker") {
112 | const image = pkg.version ? `${pkg.name}:${pkg.version}` : pkg.name;
113 | return ["run", ...runtimeArgs, image, ...packageArgs];
114 | }
115 |
116 | // Handle python runtime specially - doesn't use @version syntax
117 | if (runtimeHint === "python") {
118 | return [...runtimeArgs, pkg.name, ...packageArgs];
119 | }
120 |
121 | // For npm/npx, uvx, and other runtimes that support @version syntax:
122 | // [runtime args] [package@version] [package args]
123 | const pkgName = pkg.version ? `${pkg.name}@${pkg.version}` : pkg.name;
124 | return [...runtimeArgs, pkgName, ...packageArgs];
125 | }
126 |
127 | /**
128 | * Convert CoreSpeed ServerDetail to McpServerEndpoint
129 | */
130 | export function convertServerDetailToEndpoint(
131 | serverDetail: ServerDetail,
132 | ): McpServerEndpoint {
133 | // Prefer remote configuration if available
134 | if (serverDetail.remotes?.[0]) {
135 | const remote = serverDetail.remotes[0];
136 | const headers = convertToRecord(remote.headers);
137 |
138 | return {
139 | id: serverDetail.packageName,
140 | displayName: serverDetail.displayName,
141 | type: "remote",
142 | remote: {
143 | url: remote.url,
144 | headers,
145 | },
146 | };
147 | }
148 |
149 | // Fall back to package/command configuration
150 | if (serverDetail.packages?.[0]) {
151 | const pkg = serverDetail.packages[0];
152 |
153 | if (!pkg.runtimeHint) {
154 | throw new Error(
155 | `Package for server ${serverDetail.id} is missing runtimeHint`,
156 | );
157 | }
158 |
159 | const runtimeArgs = extractArguments(pkg.runtimeArguments);
160 | const packageArgs = extractArguments(pkg.packageArguments);
161 |
162 | const args = buildCommandArgs(pkg, runtimeArgs, packageArgs);
163 |
164 | const env = convertToRecord(pkg.environmentVariables);
165 |
166 | return {
167 | id: serverDetail.packageName,
168 | displayName: serverDetail.displayName,
169 | type: "command",
170 | command: {
171 | command: pkg.runtimeHint,
172 | args,
173 | env,
174 | },
175 | };
176 | }
177 |
178 | throw new Error(
179 | `Server ${serverDetail.id} has no valid remote or package configuration`,
180 | );
181 | }
182 |
--------------------------------------------------------------------------------
/examples/ptc/ptc.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Example: Programmatic Tool Calling
3 | *
4 | * Demonstrates programmatic tool calling where the LLM can use
5 | * execute_code to call tools like tools.get_weather() and process
6 | * the results efficiently.
7 | *
8 | * Also demonstrates tool approval handling - requiring user confirmation
9 | * before executing `execute_code` tool.
10 | *
11 | * Run:
12 | * deno run --env --allow-read --allow-net --allow-env --allow-sys ./ptc.ts
13 | */
14 |
15 | import "@std/dotenv/load";
16 | import {
17 | AnthropicModelProvider,
18 | createZypherContext,
19 | McpServerManager,
20 | ZypherAgent,
21 | } from "@zypher/agent";
22 | import { createExecuteCodeTool, createTool } from "@zypher/agent/tools";
23 | import { z } from "zod";
24 | import { eachValueFrom } from "rxjs-for-await";
25 | import { TextLineStream } from "@std/streams/text-line-stream";
26 | import chalk from "chalk";
27 |
28 | async function prompt(message: string): Promise {
29 | console.log(message);
30 | const lines = Deno.stdin.readable
31 | .pipeThrough(new TextDecoderStream())
32 | .pipeThrough(new TextLineStream());
33 | try {
34 | for await (const line of lines) {
35 | return line;
36 | }
37 | return "";
38 | } finally {
39 | await lines.cancel();
40 | }
41 | }
42 |
43 | const apiKey = Deno.env.get("ANTHROPIC_API_KEY");
44 | if (!apiKey) {
45 | console.error("Error: Set ANTHROPIC_API_KEY environment variable");
46 | Deno.exit(1);
47 | }
48 |
49 | // Create a simple weather tool
50 | const getWeather = createTool({
51 | name: "get_weather",
52 | description: "Get the current weather for a city",
53 | schema: z.object({
54 | city: z.string().describe("City name"),
55 | }),
56 | // Note: outputSchema is optional but highly RECOMMENDED for tools used with PTC.
57 | // It documents the structure of result.structuredContent, which helps the agent to
58 | // generate correct code for accessing and manipulating tool results.
59 | outputSchema: z.object({
60 | city: z.string().describe("The city name"),
61 | temperature: z.number().describe("Temperature in Celsius"),
62 | condition: z.string().describe(
63 | "Weather condition (e.g., Sunny, Cloudy, Rainy)",
64 | ),
65 | unit: z.literal("celsius").describe("Temperature unit"),
66 | }),
67 | execute: ({ city }) => {
68 | // Mock weather data for European cities
69 | const MOCK_WEATHER: Record = {
70 | paris: { temp: 8, condition: "Cloudy" },
71 | london: { temp: 6, condition: "Rainy" },
72 | berlin: { temp: 3, condition: "Snowy" },
73 | rome: { temp: 14, condition: "Sunny" },
74 | madrid: { temp: 12, condition: "Partly Cloudy" },
75 | amsterdam: { temp: 5, condition: "Windy" },
76 | vienna: { temp: 4, condition: "Foggy" },
77 | prague: { temp: 2, condition: "Cloudy" },
78 | barcelona: { temp: 16, condition: "Sunny" },
79 | lisbon: { temp: 18, condition: "Clear" },
80 | };
81 | const key = city.toLowerCase();
82 | const data = MOCK_WEATHER[key];
83 | if (!data) {
84 | throw new Error(`Weather data not available for ${city}`);
85 | }
86 | return Promise.resolve(
87 | {
88 | content: [{
89 | type: "text",
90 | text:
91 | `The weather in ${city} is ${data.condition} with a temperature of ${data.temp}°C`,
92 | }],
93 | structuredContent: {
94 | city,
95 | temperature: data.temp,
96 | condition: data.condition,
97 | unit: "celsius",
98 | },
99 | },
100 | );
101 | },
102 | });
103 |
104 | // Create context and MCP server manager with tool approval handler
105 | const context = await createZypherContext(Deno.cwd());
106 | const mcpServerManager = new McpServerManager(context, {
107 | toolApprovalHandler: async (toolName, input) => {
108 | if (toolName !== "execute_code") {
109 | return true;
110 | }
111 |
112 | const { code } = input as { code: string };
113 | console.log(`\n🤖 Zypher Agent wants to run the following code:\n`);
114 | console.log(chalk.dim("```typescript"));
115 | console.log(chalk.cyan(code));
116 | console.log(chalk.dim("```\n"));
117 |
118 | const response = await prompt("Run the code above? (y/N): ");
119 | const approved = response.toLowerCase() === "y";
120 | console.log(approved ? "✅ Approved\n" : "❌ Rejected\n");
121 | return approved;
122 | },
123 | });
124 |
125 | // Register tools
126 | mcpServerManager.registerTool(getWeather);
127 | mcpServerManager.registerTool(createExecuteCodeTool(mcpServerManager));
128 |
129 | // Create the agent with the custom MCP server manager
130 | const agent = new ZypherAgent(
131 | context,
132 | new AnthropicModelProvider({ apiKey }),
133 | {
134 | overrides: {
135 | mcpServerManager,
136 | },
137 | },
138 | );
139 |
140 | const events$ = agent.runTask(
141 | `In the following cities:
142 | - paris
143 | - london
144 | - berlin
145 | - rome
146 | - madrid
147 | - amsterdam
148 | - vienna
149 | - prague
150 | - barcelona
151 | - lisbon
152 |
153 | get two cities with the most similar weather and the city with the highest temperature`,
154 | "claude-sonnet-4-5-20250929",
155 | );
156 |
157 | const textEncoder = new TextEncoder();
158 | let isFirstTextChunk = true;
159 |
160 | try {
161 | for await (const event of eachValueFrom(events$)) {
162 | if (event.type === "text") {
163 | if (isFirstTextChunk) {
164 | await Deno.stdout.write(textEncoder.encode("🤖 "));
165 | isFirstTextChunk = false;
166 | }
167 | await Deno.stdout.write(textEncoder.encode(event.content));
168 | } else {
169 | isFirstTextChunk = true;
170 |
171 | if (event.type === "tool_use") {
172 | console.log(`\n🔧 Using tool: ${event.toolName}`);
173 | } else if (event.type === "tool_use_result") {
174 | console.log(`📋 Tool result: ${event.toolName} (${event.toolUseId})`);
175 | console.log(event.result);
176 | console.log();
177 | }
178 | }
179 | }
180 |
181 | console.log("\n");
182 | console.log(chalk.green("✅ Task completed.\n"));
183 | Deno.exit(0);
184 | } catch (error) {
185 | console.error(error);
186 | Deno.exit(1);
187 | }
188 |
--------------------------------------------------------------------------------
/examples/mcp/connectToRemoteServer.example.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Low-Level OAuth Connection Example
3 | *
4 | * This example demonstrates how to use the low-level `connectToRemoteServer` API
5 | * to connect to OAuth-enabled MCP servers. This function accepts a `Client` instance
6 | * from the MCP SDK and handles connection logic, transport fallback, and OAuth flow.
7 | *
8 | * NOTE: For most use cases, prefer the high-level `McpClient` API which wraps the
9 | * MCP SDK's `Client` with a state machine that manages connection lifecycle, OAuth,
10 | * reconnection, and exposes a simple `desiredEnabled` API for control.
11 | * See the `McpClient.example.ts` example for the recommended approach.
12 | *
13 | * The OAuth flow in this example:
14 | * 1. Prints an authorization URL for you to visit
15 | * 2. You authorize the application in your browser
16 | * 3. You copy and paste the callback URL back
17 | * 4. Script completes OAuth flow and connects to the MCP server
18 | * 5. Displays server capabilities (tools, resources, prompts)
19 | *
20 | * Usage:
21 | * deno run --allow-all connectToRemoteServer.example.ts
22 | *
23 | * Examples:
24 | * deno run --allow-all connectToRemoteServer.example.ts https://your-mcp-server.com/mcp
25 | * deno run --allow-all connectToRemoteServer.example.ts http://localhost:8080/mcp
26 | */
27 |
28 | import { Command } from "@cliffy/command";
29 | import { Client } from "@modelcontextprotocol/sdk/client/index.js";
30 | import {
31 | connectToRemoteServer,
32 | InMemoryOAuthProvider,
33 | type McpRemoteConfig,
34 | } from "@zypher/agent";
35 | import { CliOAuthCallbackHandler } from "@zypher/cli";
36 |
37 | async function run(serverUrl: string) {
38 | console.log("🔗 MCP OAuth Connection Example");
39 | console.log("================================");
40 | console.log(`Server URL: ${serverUrl}`);
41 | console.log("");
42 |
43 | let client: Client | null = null;
44 |
45 | try {
46 | // Create OAuth provider with console-based redirect handling
47 | const oauthProvider = new InMemoryOAuthProvider({
48 | clientMetadata: {
49 | redirect_uris: ["http://localhost:8080/mcp/oauth/callback"],
50 | token_endpoint_auth_method: "client_secret_post",
51 | grant_types: ["authorization_code", "refresh_token"],
52 | response_types: ["code"],
53 | client_name: "MCP OAuth Example Client",
54 | client_uri: "https://github.com/anthropics/zypher-agent",
55 | software_id: "zypher-mcp-oauth-example",
56 | software_version: "1.0.0",
57 | },
58 | onRedirect: (authorizationUrl: string) => {
59 | console.log("\n🌐 AUTHORIZATION REQUIRED");
60 | console.log("========================");
61 | console.log("Please visit this URL to authorize the application:");
62 | console.log("");
63 | console.log(` ${authorizationUrl}`);
64 | console.log("");
65 | },
66 | });
67 | const callbackHandler = new CliOAuthCallbackHandler();
68 |
69 | // Create client
70 | client = new Client({
71 | name: "mcp-oauth-example-client",
72 | version: "1.0.0",
73 | });
74 |
75 | const remoteConfig: McpRemoteConfig = {
76 | url: serverUrl,
77 | };
78 |
79 | // Start the connection process (this will trigger OAuth flow)
80 | await connectToRemoteServer(client, remoteConfig, {
81 | oauth: {
82 | authProvider: oauthProvider,
83 | callbackHandler: callbackHandler,
84 | },
85 | });
86 |
87 | console.log("🎉 Connected to MCP server successfully!");
88 | console.log("");
89 |
90 | // Test server capabilities
91 | console.log("📊 Server Capabilities");
92 | console.log("======================");
93 |
94 | // List available tools
95 | try {
96 | const toolResult = await client.listTools();
97 | console.log(`🔧 Tools (${toolResult.tools.length}):`);
98 | if (toolResult.tools.length === 0) {
99 | console.log(" No tools available");
100 | } else {
101 | toolResult.tools.forEach((tool, index) => {
102 | console.log(` ${index + 1}. ${tool.name} - ${tool.description}`);
103 | });
104 | }
105 | } catch (error) {
106 | console.log(
107 | "🔧 Tools: Error listing tools -",
108 | error instanceof Error ? error.message : "Unknown error",
109 | );
110 | }
111 |
112 | console.log("");
113 |
114 | // List available resources
115 | try {
116 | const resourceResult = await client.listResources();
117 | console.log(`📁 Resources (${resourceResult.resources.length}):`);
118 | if (resourceResult.resources.length === 0) {
119 | console.log(" No resources available");
120 | } else {
121 | resourceResult.resources.forEach((resource, index) => {
122 | console.log(
123 | ` ${index + 1}. ${resource.name} - ${
124 | resource.description || "No description"
125 | }`,
126 | );
127 | });
128 | }
129 | } catch (_error) {
130 | console.log("📁 Resources: Not supported or none available");
131 | }
132 |
133 | console.log("");
134 |
135 | // List available prompts
136 | try {
137 | const promptResult = await client.listPrompts();
138 | console.log(`💬 Prompts (${promptResult.prompts.length}):`);
139 | if (promptResult.prompts.length === 0) {
140 | console.log(" No prompts available");
141 | } else {
142 | promptResult.prompts.forEach((prompt, index) => {
143 | console.log(
144 | ` ${index + 1}. ${prompt.name} - ${
145 | prompt.description || "No description"
146 | }`,
147 | );
148 | });
149 | }
150 | } catch (_error) {
151 | console.log("💬 Prompts: Not supported or none available");
152 | }
153 |
154 | console.log("");
155 | console.log("✨ OAuth connection example completed successfully!");
156 | } catch (error) {
157 | console.error("");
158 | console.error("❌ OAuth connection failed:", error);
159 | Deno.exit(1);
160 | } finally {
161 | // Clean up
162 | if (client) {
163 | try {
164 | await client.close();
165 | } catch (_error) {
166 | // Ignore cleanup errors
167 | }
168 | }
169 | }
170 | }
171 |
172 | if (import.meta.main) {
173 | await new Command()
174 | .name("connectToRemoteServer")
175 | .version("1.0.0")
176 | .description(
177 | "Connect to an OAuth-enabled MCP server using the low-level connectToRemoteServer API",
178 | )
179 | .arguments("")
180 | .example(
181 | "Remote server",
182 | "deno run --allow-all connectToRemoteServer.example.ts https://mcp-server.com/mcp",
183 | )
184 | .action((_options, serverUrl) => run(serverUrl))
185 | .parse(Deno.args);
186 | }
187 |
--------------------------------------------------------------------------------
/packages/agent/src/tools/fs/ReadFileTool.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import {
3 | createTool,
4 | type Tool,
5 | type ToolExecutionContext,
6 | type ToolResult,
7 | } from "../mod.ts";
8 | import * as path from "@std/path";
9 | import { encodeBase64 } from "@std/encoding/base64";
10 |
11 | // Supported image types that can be displayed as rich content
12 | const SUPPORTED_IMAGE_TYPES = [
13 | "image/jpeg",
14 | "image/png",
15 | "image/gif",
16 | "image/webp",
17 | ] as const;
18 |
19 | // File extension to MIME type mapping
20 | const FILE_EXTENSION_TO_MIME: Record = {
21 | ".jpg": "image/jpeg",
22 | ".jpeg": "image/jpeg",
23 | ".png": "image/png",
24 | ".gif": "image/gif",
25 | ".webp": "image/webp",
26 | };
27 |
28 | function getMimeTypeFromPath(filePath: string): string | undefined {
29 | const ext = path.extname(filePath).toLowerCase();
30 | return FILE_EXTENSION_TO_MIME[ext];
31 | }
32 |
33 | function isSupportedImageType(
34 | mimeType: string,
35 | ): mimeType is typeof SUPPORTED_IMAGE_TYPES[number] {
36 | return SUPPORTED_IMAGE_TYPES.includes(
37 | mimeType as typeof SUPPORTED_IMAGE_TYPES[number],
38 | );
39 | }
40 |
41 | /**
42 | * Detects if a file is likely binary by checking for null bytes in the first 1KB.
43 | * This is a heuristic approach - text files typically don't contain null bytes,
44 | * while binary files (executables, images, archives, etc.) often do.
45 | * Not 100% accurate but catches most common binary formats.
46 | */
47 | async function isLikelyBinaryFile(filePath: string): Promise {
48 | const file = await Deno.open(filePath, { read: true });
49 | const buffer = new Uint8Array(1024); // Sample first 1KB
50 |
51 | let bytesRead: number | null = null;
52 | try {
53 | bytesRead = await file.read(buffer);
54 | } finally {
55 | file.close();
56 | }
57 |
58 | if (bytesRead === null || bytesRead === 0) {
59 | return false; // Empty file, treat as text
60 | }
61 |
62 | const chunk = buffer.slice(0, bytesRead);
63 | // Check for null bytes (0x00) which are common in binary files
64 | return chunk.some((byte) => byte === 0);
65 | }
66 |
67 | export const ReadFileTool: Tool<{
68 | filePath: string;
69 | startLine?: number;
70 | endLine?: number;
71 | explanation?: string | undefined;
72 | }> = createTool({
73 | name: "read_file",
74 | description:
75 | `Read the contents of a file. Supports text files (with line-based reading) and images (JPEG, PNG, GIF, WebP, SVG) as rich content.
76 |
77 | For text files: if startLine and endLine are provided, reads the specified line range (1-indexed, inclusive).
78 | If no line range is specified, reads the entire file.
79 |
80 | For images: the entire file content will be returned as rich media that can be displayed inline.
81 |
82 | For binary files: detects binary content and provides file metadata instead of attempting text display.
83 |
84 | When using this tool to gather information from text files, it's your responsibility to ensure you have the COMPLETE context.
85 | Specifically, each time you call this command you should:
86 | 1) Assess if the contents you viewed are sufficient to proceed with your task.
87 | 2) Take note of where there are lines not shown.
88 | 3) If the file contents you have viewed are insufficient, and you suspect they may be in lines not shown, proactively call the tool again to view those lines.
89 | 4) When in doubt, call this tool again to gather more information. Remember that partial file views may miss critical dependencies, imports, or functionality.`,
90 | schema: z.object({
91 | filePath: z
92 | .string()
93 | .describe(
94 | "The path of the file to read (relative or absolute). Supports text files and images (JPEG, PNG, GIF, WebP, SVG).",
95 | ),
96 | startLine: z
97 | .number()
98 | .optional()
99 | .describe(
100 | "The one-indexed line number to start reading from (inclusive). If not provided, reads entire file.",
101 | ),
102 | endLine: z
103 | .number()
104 | .optional()
105 | .describe(
106 | "The one-indexed line number to end reading at (inclusive). If not provided, reads entire file.",
107 | ),
108 | explanation: z
109 | .string()
110 | .optional()
111 | .describe(
112 | "One sentence explanation as to why this tool is being used, and how it contributes to the goal.",
113 | ),
114 | }),
115 | execute: async (
116 | {
117 | filePath,
118 | startLine,
119 | endLine,
120 | },
121 | ctx: ToolExecutionContext,
122 | ): Promise => {
123 | const resolvedPath = path.resolve(ctx.workingDirectory, filePath);
124 | const mimeType = getMimeTypeFromPath(filePath);
125 |
126 | // Handle images
127 | if (mimeType && isSupportedImageType(mimeType)) {
128 | const fileBytes = await Deno.readFile(resolvedPath);
129 | const base64Data = encodeBase64(fileBytes);
130 |
131 | return {
132 | content: [
133 | {
134 | type: "text",
135 | text: `Reading image file: ${filePath} (${mimeType})`,
136 | },
137 | {
138 | type: "image",
139 | data: base64Data,
140 | mimeType: mimeType,
141 | },
142 | ],
143 | };
144 | }
145 |
146 | // Check if file is binary before attempting to read as text
147 | const isLikelyBinary = await isLikelyBinaryFile(resolvedPath);
148 | if (isLikelyBinary) {
149 | const fileStats = await Deno.stat(resolvedPath);
150 | return {
151 | content: [{
152 | type: "text",
153 | text: `Unable to read ${filePath} as text (likely a binary file)`,
154 | }],
155 | structuredContent: {
156 | type: "file_info",
157 | path: filePath,
158 | fileType: "binary",
159 | size: fileStats.size,
160 | lastModified: fileStats.mtime?.toISOString(),
161 | },
162 | };
163 | }
164 |
165 | const content = await Deno.readTextFile(resolvedPath);
166 |
167 | // If no line range specified, return entire file
168 | if (startLine === undefined || endLine === undefined) {
169 | return content;
170 | }
171 |
172 | const lines = content.split("\n");
173 |
174 | const startIdx = Math.max(0, startLine - 1);
175 | const endIdx = Math.min(lines.length, endLine - 1);
176 |
177 | const selectedLines = lines.slice(startIdx, endIdx + 1); // +1 for slice exclusivity
178 | let result = "";
179 |
180 | // Add summary of lines before selection
181 | if (startIdx > 0) {
182 | result += `[Lines 1-${startIdx} not shown]\n\n`;
183 | }
184 |
185 | result += selectedLines.join("\n");
186 |
187 | // Add summary of lines after selection
188 | if (endLine < lines.length) {
189 | result += `\n\n[Lines ${endLine + 1}-${lines.length} not shown]`;
190 | }
191 |
192 | return result;
193 | },
194 | });
195 |
--------------------------------------------------------------------------------