├── 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 | 21 | 22 | 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 | [![Build](https://github.com/corespeed-io/zypher-agent/actions/workflows/build.yml/badge.svg)](https://github.com/corespeed-io/zypher-agent/actions/workflows/build.yml) 6 | [![JSR](https://jsr.io/badges/@zypher/agent)](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 | --------------------------------------------------------------------------------