Compare commits

...

7 Commits

Author SHA1 Message Date
Ibrahim Kazimov 167085c3a7
Merge branch 'main' into feat.mcp-tool-integration 2026-04-12 00:03:18 +03:00
MrAvalonApple 12dd802ad8 feat: update MCP GitHub example and added llmInputSchema 2026-04-12 00:01:22 +03:00
JackChen 1fbed196ca Revert "ci: add release workflow triggered by v*.*.* tags" 2026-04-11 15:25:24 +08:00
JackChen a220b6ecc5 ci: add release workflow triggered by v*.*.* tags 2026-04-11 15:23:41 +08:00
JackChen 89311dc4d4 chore: release 1.1.0 2026-04-11 15:15:15 +08:00
JackChen 06cc415ddf
docs: rewrite README top fold and trim Examples section (#95)
Strategic rewrite following docs/project-evaluation-2026-04-09.md.
README.md and README_zh.md updated in lockstep.

Top fold changes:
- New tagline positioning against CrewAI and LangGraph
- Replace 11-bullet "Why" with 3 bullets (runTeam / 3 deps / multi-model)
- New Philosophy section with "we build / we don't build / tracking"
- "We don't build" limited to handoffs and checkpointing (softened);
  Cloud/Studio bullet dropped to preserve future Hosted option
- New "How is this different from X?" FAQ covering LangGraph JS, CrewAI,
  and Vercel AI SDK
- New "Used by" section with three early-stage integrations, framed
  honestly for a new project (temodar-agent, rentech-quant-platform,
  cybersecurity SOC home lab)

Examples section:
- Shrink 15-row catalog table to 4 featured entries + link to examples/
- Featured: 02 team collaboration, 06 local model, 09 structured output,
  11 trace observability
- Eliminates maintenance debt of updating the table on every new example

Refinements during alignment pass:
- Launch date corrected to 2026-04-01 (matches first commit timestamp)
- Surface Gemini @google/genai peer dep in top fold and Providers table
- Rephrase "Agent handoffs" bullet to avoid reading as single-agent framework
- Update prose example to Opus 4.6 / GPT-5.4 / local Gemma 4
- Quick Start code example shortened ~30% (developer/reviewer collapsed
  to stubs, still demonstrates multi-agent team shape)
- Remove CrewAI endorsement stats (48K stars / Andrew Ng / $18M) to keep
  comparisons technical
- Drop Star History cache-buster since growth has stabilized; bump
  contributors cache-buster to max=20 so all 8 contributors render
- Delete Author section; shrink Contributing to Examples + Documentation

Small carry-over fixes:
- Fix duplicated task_complete line in Quick Start output sample
- Add AgentPool.runParallel() note to Three Ways to Run
- Update source file count 33 → 35

Kept unchanged per scope:
- Architecture diagram, Built-in Tools, Supported Providers

Does not touch source code or package.json.
2026-04-11 14:19:03 +08:00
Ibrahim Kazimov aa5fab59fa
feat: enforce dependency-scoped agent context (default-deny) (#87)
Co-authored-by: MrAvalonApple <74775400+ibrahimkazimov@users.noreply.github.com>
2026-04-10 03:09:58 +08:00
16 changed files with 626 additions and 174 deletions

123
README.md
View File

@ -1,8 +1,10 @@
# Open Multi-Agent
TypeScript framework for multi-agent orchestration. One `runTeam()` call from goal to result — the framework decomposes it into tasks, resolves dependencies, and runs agents in parallel.
The lightweight multi-agent orchestration engine for TypeScript. Three runtime dependencies, zero config, goal to result in one `runTeam()` call.
3 runtime dependencies · 33 source files · Deploys anywhere Node.js runs · Mentioned in [Latent Space](https://www.latent.space/p/ainews-a-quiet-april-fools) AI News
CrewAI is Python. LangGraph makes you draw the graph by hand. `open-multi-agent` is the `npm install` you drop into an existing Node.js backend when you need a team of agents to work on a goal together. Nothing more, nothing less.
3 runtime dependencies · 35 source files · Deploys anywhere Node.js runs · Mentioned in [Latent Space](https://www.latent.space/p/ainews-a-quiet-april-fools) AI News (top AI engineering newsletter, 170k+ subscribers)
[![GitHub stars](https://img.shields.io/github/stars/JackChen-me/open-multi-agent)](https://github.com/JackChen-me/open-multi-agent/stargazers)
[![license](https://img.shields.io/github/license/JackChen-me/open-multi-agent)](./LICENSE)
@ -11,19 +13,51 @@ TypeScript framework for multi-agent orchestration. One `runTeam()` call from go
**English** | [中文](./README_zh.md)
## Why Open Multi-Agent?
## What you actually get
- **Goal In, Result Out**`runTeam(team, "Build a REST API")`. A coordinator agent auto-decomposes the goal into a task DAG with dependencies and assignees, runs independent tasks in parallel, and synthesizes the final output. No manual task definitions or graph wiring required.
- **TypeScript-Native** — Built for the Node.js ecosystem. `npm install`, import, run. No Python runtime, no subprocess bridge, no sidecar services. Embed in Express, Next.js, serverless functions, or CI/CD pipelines.
- **Auditable and Lightweight** — 3 runtime dependencies (`@anthropic-ai/sdk`, `openai`, `zod`). 33 source files. The entire codebase is readable in an afternoon.
- **Model Agnostic** — Claude, GPT, Gemma 4, and local models (Ollama, vLLM, LM Studio, llama.cpp server) in the same team. Swap models per agent via `baseURL`.
- **Multi-Agent Collaboration** — Agents with different roles, tools, and models collaborate through a message bus and shared memory.
- **Structured Output** — Add `outputSchema` (Zod) to any agent. Output is parsed as JSON, validated, and auto-retried once on failure. Access typed results via `result.structured`.
- **Task Retry** — Set `maxRetries` on tasks for automatic retry with exponential backoff. Failed attempts accumulate token usage for accurate billing.
- **Human-in-the-Loop** — Optional `onApproval` callback on `runTasks()`. After each batch of tasks completes, your callback decides whether to proceed or abort remaining work.
- **Lifecycle Hooks**`beforeRun` / `afterRun` on `AgentConfig`. Intercept the prompt before execution or post-process results after. Throw from either hook to abort.
- **Loop Detection**`loopDetection` on `AgentConfig` catches stuck agents repeating the same tool calls or text output. Configurable action: warn (default), terminate, or custom callback.
- **Observability** — Optional `onTrace` callback emits structured spans for every LLM call, tool execution, task, and agent run — with timing, token usage, and a shared `runId` for correlation. Zero overhead when not subscribed, zero extra dependencies.
- **Goal to result in one call.** `runTeam(team, "Build a REST API")` kicks off a coordinator agent that decomposes the goal into a task DAG, resolves dependencies, runs independent tasks in parallel, and synthesizes the final output. No graph to draw, no tasks to wire up.
- **TypeScript-native, three runtime dependencies.** `@anthropic-ai/sdk`, `openai`, `zod`. That is the whole runtime. Embed in Express, Next.js, serverless functions, or CI/CD pipelines. No Python runtime, no subprocess bridge, no cloud sidecar.
- **Multi-model teams.** Claude, GPT, Gemini, Grok, Copilot, or any OpenAI-compatible local model (Ollama, vLLM, LM Studio, llama.cpp) in the same team. Run the architect on Opus 4.6, the developer on GPT-5.4, the reviewer on local Gemma 4, all in one `runTeam()` call. Gemini ships as an optional peer dependency: `npm install @google/genai` to enable.
Other features (structured output, task retry, human-in-the-loop, lifecycle hooks, loop detection, observability) live below the fold and in [`examples/`](./examples/).
## Philosophy: what we build, what we don't
Our goal is to be the simplest multi-agent framework for TypeScript. Simplicity does not mean closed. We believe the long-term value of a framework is the size of the network it connects to, not its feature checklist.
**We build:**
- A coordinator that decomposes a goal into a task DAG.
- A task queue that runs independent tasks in parallel and cascades failures to dependents.
- A shared memory and message bus so agents can see each other's output.
- Multi-model teams where each agent can use a different LLM provider.
**We don't build:**
- **Agent handoffs.** If agent A needs to transfer mid-conversation to agent B, use [OpenAI Agents SDK](https://github.com/openai/openai-agents-python). In our model, each agent owns one task end-to-end, with no mid-conversation transfers.
- **State persistence / checkpointing.** Not planned for now. Adding a storage backend would break the three-dependency promise, and our workflows run in seconds to minutes, not hours. If real usage shifts toward long-running workflows, we will revisit.
**Tracking:**
- **MCP support.** Next up, see [#86](https://github.com/JackChen-me/open-multi-agent/issues/86).
- **A2A protocol.** Watching, will move when production adoption is real.
See [`DECISIONS.md`](./DECISIONS.md) for the full rationale.
## How is this different from X?
**vs. [LangGraph JS](https://github.com/langchain-ai/langgraphjs).** LangGraph is declarative graph orchestration: you define nodes, edges, and conditional routing, then `compile()` and `invoke()`. `open-multi-agent` is goal-driven: you declare a team and a goal, a coordinator decomposes it into a task DAG at runtime. LangGraph gives you total control of topology (great for fixed production workflows). This gives you less typing and faster iteration (great for exploratory multi-agent work). LangGraph also has mature checkpointing; we do not.
**vs. [CrewAI](https://github.com/crewAIInc/crewAI).** CrewAI is the mature Python choice. If your stack is Python, use CrewAI. `open-multi-agent` is TypeScript-native: three runtime dependencies, embeds directly in Node.js without a subprocess bridge. Roughly comparable capability on the orchestration side. Choose on language fit.
**vs. [Vercel AI SDK](https://github.com/vercel/ai).** AI SDK is the LLM call layer: a unified TypeScript client for 60+ providers with streaming, tool calls, and structured outputs. It does not orchestrate multi-agent teams. `open-multi-agent` sits on top when you need that. They compose: use AI SDK for single-agent work, reach for this when you need a team.
## Used by
`open-multi-agent` is a new project (launched 2026-04-01, MIT, 5,500+ stars). The ecosystem is still forming, so the list below is short and honest:
- **[temodar-agent](https://github.com/xeloxa/temodar-agent)** (~50 stars). WordPress security analysis platform by [Ali Sünbül](https://github.com/xeloxa). Uses our built-in tools (`bash`, `file_*`, `grep`) directly in its Docker runtime. Confirmed production use.
- **[rentech-quant-platform](https://github.com/rookiecoderasz/rentech-quant-platform).** Multi-agent quant trading research platform. Five pipelines plus MCP integrations, built on top of `open-multi-agent`. Early signal, very new.
- **Cybersecurity SOC (home lab).** A private setup running Qwen 2.5 + DeepSeek Coder entirely offline via Ollama, building an autonomous SOC pipeline on Wazuh + Proxmox. Early user, not yet public.
Using `open-multi-agent` in production or a side project? [Open a discussion](https://github.com/JackChen-me/open-multi-agent/discussions) and we will list it here.
## Quick Start
@ -38,6 +72,7 @@ Set the API key for your provider. Local models via Ollama require no API key
- `ANTHROPIC_API_KEY`
- `OPENAI_API_KEY`
- `GEMINI_API_KEY`
- `XAI_API_KEY` (for Grok)
- `GITHUB_TOKEN` (for Copilot)
Three agents, one goal — the framework handles the rest:
@ -53,19 +88,8 @@ const architect: AgentConfig = {
tools: ['file_write'],
}
const developer: AgentConfig = {
name: 'developer',
model: 'claude-sonnet-4-6',
systemPrompt: 'You implement what the architect designs.',
tools: ['bash', 'file_read', 'file_write', 'file_edit'],
}
const reviewer: AgentConfig = {
name: 'reviewer',
model: 'claude-sonnet-4-6',
systemPrompt: 'You review code for correctness and clarity.',
tools: ['file_read', 'grep'],
}
const developer: AgentConfig = { /* same shape, tools: ['bash', 'file_read', 'file_write', 'file_edit'] */ }
const reviewer: AgentConfig = { /* same shape, tools: ['file_read', 'grep'] */ }
const orchestrator = new OpenMultiAgent({
defaultModel: 'claude-sonnet-4-6',
@ -94,8 +118,8 @@ task_complete architect
task_start developer
task_start developer // independent tasks run in parallel
task_complete developer
task_start reviewer // unblocked after implementation
task_complete developer
task_start reviewer // unblocked after implementation
task_complete reviewer
agent_complete coordinator // synthesizes final result
Success: true
@ -110,30 +134,18 @@ Tokens: 12847 output tokens
| Auto-orchestrated team | `runTeam()` | Give a goal, framework plans and executes |
| Explicit pipeline | `runTasks()` | You define the task graph and assignments |
For MapReduce-style fan-out without task dependencies, use `AgentPool.runParallel()` directly. See [example 07](examples/07-fan-out-aggregate.ts).
## Examples
All examples are runnable scripts in [`examples/`](./examples/). Run any of them with `npx tsx`:
16 runnable scripts in [`examples/`](./examples/). Start with these four:
```bash
npx tsx examples/01-single-agent.ts
```
- [02 — Team Collaboration](examples/02-team-collaboration.ts): `runTeam()` coordinator pattern.
- [06 — Local Model](examples/06-local-model.ts): Ollama and Claude in one pipeline via `baseURL`.
- [09 — Structured Output](examples/09-structured-output.ts): any agent returns Zod-validated JSON.
- [11 — Trace Observability](examples/11-trace-observability.ts): `onTrace` spans for LLM calls, tools, and tasks.
| Example | What it shows |
|---------|---------------|
| [01 — Single Agent](examples/01-single-agent.ts) | `runAgent()` one-shot, `stream()` streaming, `prompt()` multi-turn |
| [02 — Team Collaboration](examples/02-team-collaboration.ts) | `runTeam()` auto-orchestration with coordinator pattern |
| [03 — Task Pipeline](examples/03-task-pipeline.ts) | `runTasks()` explicit dependency graph (design → implement → test + review) |
| [04 — Multi-Model Team](examples/04-multi-model-team.ts) | `defineTool()` custom tools, mixed Anthropic + OpenAI providers, `AgentPool` |
| [05 — Copilot](examples/05-copilot-test.ts) | GitHub Copilot as an LLM provider |
| [06 — Local Model](examples/06-local-model.ts) | Ollama + Claude in one pipeline via `baseURL` (works with vLLM, LM Studio, etc.) |
| [07 — Fan-Out / Aggregate](examples/07-fan-out-aggregate.ts) | `runParallel()` MapReduce — 3 analysts in parallel, then synthesize |
| [08 — Gemma 4 Local](examples/08-gemma4-local.ts) | `runTasks()` + `runTeam()` with local Gemma 4 via Ollama — zero API cost |
| [09 — Structured Output](examples/09-structured-output.ts) | `outputSchema` (Zod) on AgentConfig — validated JSON via `result.structured` |
| [10 — Task Retry](examples/10-task-retry.ts) | `maxRetries` / `retryDelayMs` / `retryBackoff` with `task_retry` progress events |
| [11 — Trace Observability](examples/11-trace-observability.ts) | `onTrace` callback — structured spans for LLM calls, tools, tasks, and agents |
| [12 — Grok](examples/12-grok.ts) | Same as example 02 (`runTeam()` collaboration) with Grok (`XAI_API_KEY`) |
| [13 — Gemini](examples/13-gemini.ts) | Gemini adapter smoke test with `gemini-2.5-flash` (`GEMINI_API_KEY`) |
| [16 — MCP GitHub Tools](examples/16-mcp-github.ts) | Connect MCP over stdio and use server tools as native `ToolDefinition`s |
Run any with `npx tsx examples/02-team-collaboration.ts`.
## Architecture
@ -272,6 +284,8 @@ Notes:
| Ollama / vLLM / LM Studio | `provider: 'openai'` + `baseURL` | — | Verified |
| llama.cpp server | `provider: 'openai'` + `baseURL` | — | Verified |
Gemini requires `npm install @google/genai` (optional peer dependency).
Verified local models with tool-calling: **Gemma 4** (see [example 08](examples/08-gemma4-local.ts)).
Any OpenAI-compatible API should work via `provider: 'openai'` + `baseURL` (DeepSeek, Groq, Mistral, Qwen, MiniMax, etc.). **Grok now has first-class support** via `provider: 'grok'`.
@ -320,27 +334,22 @@ const grokAgent: AgentConfig = {
Issues, feature requests, and PRs are welcome. Some areas where contributions would be especially valuable:
- **Provider integrations** — Verify and document OpenAI-compatible providers (DeepSeek, Groq, Qwen, MiniMax, etc.) via `baseURL`. See [#25](https://github.com/JackChen-me/open-multi-agent/issues/25). For providers that are NOT OpenAI-compatible (e.g. Gemini), a new `LLMAdapter` implementation is welcome — the interface requires just two methods: `chat()` and `stream()`.
- **Examples** — Real-world workflows and use cases.
- **Documentation** — Guides, tutorials, and API docs.
## Author
> JackChen — Ex PM (¥100M+ revenue), now indie builder. Follow on [X](https://x.com/JackChen_x) for AI Agent insights.
## Contributors
<a href="https://github.com/JackChen-me/open-multi-agent/graphs/contributors">
<img src="https://contrib.rocks/image?repo=JackChen-me/open-multi-agent&v=20260408" />
<img src="https://contrib.rocks/image?repo=JackChen-me/open-multi-agent&max=20&v=20260411" />
</a>
## Star History
<a href="https://star-history.com/#JackChen-me/open-multi-agent&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date&theme=dark&v=20260408" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date&v=20260408" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date&v=20260408" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date" />
</picture>
</a>

View File

@ -1,8 +1,10 @@
# Open Multi-Agent
TypeScript 多智能体编排框架。一次 `runTeam()` 调用从目标到结果——框架自动拆解任务、解析依赖、并行执行
面向 TypeScript 的轻量多智能体编排引擎。3 个运行时依赖,零配置,一次 `runTeam()` 调用从目标到结果。
3 个运行时依赖 · 33 个源文件 · Node.js 能跑的地方都能部署 · 被 [Latent Space](https://www.latent.space/p/ainews-a-quiet-april-fools) AI News 提及AI 工程领域头部 Newsletter17 万+订阅者)
CrewAI 是 Python。LangGraph 需要你自己画图。`open-multi-agent` 是你现有 Node.js 后端里 `npm install` 一下就能用的那一层。当你需要让一支 agent 团队围绕一个目标协作时,只提供这个,不多不少。
3 个运行时依赖 · 35 个源文件 · Node.js 能跑的地方都能部署 · 被 [Latent Space](https://www.latent.space/p/ainews-a-quiet-april-fools) AI News 提及AI 工程领域头部 Newsletter17 万+订阅者)
[![GitHub stars](https://img.shields.io/github/stars/JackChen-me/open-multi-agent)](https://github.com/JackChen-me/open-multi-agent/stargazers)
[![license](https://img.shields.io/github/license/JackChen-me/open-multi-agent)](./LICENSE)
@ -11,19 +13,51 @@ TypeScript 多智能体编排框架。一次 `runTeam()` 调用从目标到结
[English](./README.md) | **中文**
## 为什么选择 Open Multi-Agent
## 你真正得到的三件事
- **目标进,结果出**`runTeam(team, "构建一个 REST API")`。协调者智能体自动将目标拆解为带依赖关系的任务图,分配给对应智能体,独立任务并行执行,最终合成输出。无需手动定义任务或编排流程图。
- **TypeScript 原生** — 为 Node.js 生态而生。`npm install` 即用,无需 Python 运行时、无子进程桥接、无额外基础设施。可嵌入 Express、Next.js、Serverless 函数或 CI/CD 流水线。
- **可审计、极轻量** — 3 个运行时依赖(`@anthropic-ai/sdk`、`openai`、`zod`33 个源文件。一个下午就能读完全部源码。
- **模型无关** — Claude、GPT、Gemma 4 和本地模型Ollama、vLLM、LM Studio、llama.cpp server可以在同一个团队中使用。通过 `baseURL` 即可接入任何 OpenAI 兼容服务。
- **多智能体协作** — 定义不同角色、工具和模型的智能体,通过消息总线和共享内存协作。
- **结构化输出** — 为任意智能体添加 `outputSchema`Zod输出自动解析为 JSON 并校验,校验失败自动重试一次。通过 `result.structured` 获取类型化结果。
- **任务重试** — 为任务设置 `maxRetries`,失败时自动指数退避重试。所有尝试的 token 用量累计,确保计费准确。
- **人机协同**`runTasks()` 支持可选的 `onApproval` 回调。每批任务完成后,由你的回调决定是否继续执行后续任务。
- **生命周期钩子**`AgentConfig` 上的 `beforeRun` / `afterRun`。在执行前拦截 prompt或在执行后处理结果。从钩子中 throw 可中止运行。
- **循环检测**`AgentConfig` 上的 `loopDetection` 可检测智能体重复相同工具调用或文本输出的卡死循环。可配置行为:警告(默认)、终止、或自定义回调。
- **可观测性** — 可选的 `onTrace` 回调为每次 LLM 调用、工具执行、任务和智能体运行发出结构化 span 事件——包含耗时、token 用量和共享的 `runId` 用于关联追踪。未订阅时零开销,零额外依赖。
- **一次调用从目标到结果。** `runTeam(team, "构建一个 REST API")` 启动一个协调者 agent把目标拆成任务 DAG解析依赖独立任务并行执行最终合成输出。不需要画图不需要手动连任务。
- **TypeScript 原生3 个运行时依赖。** `@anthropic-ai/sdk`、`openai`、`zod`。这就是全部运行时。可嵌入 Express、Next.js、Serverless 函数或 CI/CD 流水线。没有 Python 运行时,没有子进程桥接,没有云端 sidecar。
- **多模型团队。** Claude、GPT、Gemini、Grok、Copilot或任何 OpenAI 兼容的本地模型Ollama、vLLM、LM Studio、llama.cpp可以在同一个团队中使用。让架构师用 Opus 4.6,开发者用 GPT-5.4,评审用本地的 Gemma 4一次 `runTeam()` 调用全部搞定。Gemini 作为 optional peer dependency 提供:使用前需 `npm install @google/genai`
其他能力(结构化输出、任务重试、人机协同、生命周期钩子、循环检测、可观测性)在下方章节和 [`examples/`](./examples/) 里。
## 哲学:我们做什么,不做什么
我们的目标是做 TypeScript 生态里最简单的多智能体框架。简单不等于封闭。框架的长期价值不在于功能清单的长度,而在于它连接的网络有多大。
**我们做:**
- 一个协调者,把目标拆成任务 DAG。
- 一个任务队列,独立任务并行执行,失败级联到下游。
- 共享内存和消息总线,让 agent 之间能看到彼此的输出。
- 多模型团队,每个 agent 可以用不同的 LLM provider。
**我们不做:**
- **Agent Handoffs。** 如果 agent A 需要把对话中途交接给 agent B去用 [OpenAI Agents SDK](https://github.com/openai/openai-agents-python)。在我们的模型里,每个 agent 完整负责自己的任务,不会中途交接。
- **状态持久化 / 检查点。** 短期内不做。加存储后端会打破 3 个依赖的承诺,而且我们的工作流执行时间是秒到分钟级,不是小时级。如果真实使用场景转向长时间工作流,我们会重新评估。
**正在跟踪:**
- **MCP 支持。** 下一个要做的,见 [#86](https://github.com/JackChen-me/open-multi-agent/issues/86)。
- **A2A 协议。** 观望中,等生产级采纳到位再行动。
完整理由见 [`DECISIONS.md`](./DECISIONS.md)。
## 和 X 有什么不同?
**vs. [LangGraph JS](https://github.com/langchain-ai/langgraphjs)。** LangGraph 是声明式图编排:你定义节点、边、条件路由,然后 `compile()` + `invoke()`。`open-multi-agent` 是目标驱动:你声明团队和目标,协调者在运行时把目标拆成任务 DAG。LangGraph 给你完全的拓扑控制适合固定的生产工作流。这个框架代码更少、迭代更快适合探索型多智能体协作。LangGraph 还有成熟的检查点能力,我们没有。
**vs. [CrewAI](https://github.com/crewAIInc/crewAI)。** CrewAI 是成熟的 Python 选择。如果你的技术栈是 Python用 CrewAI。`open-multi-agent` 是 TypeScript 原生3 个运行时依赖,直接嵌入 Node.js不需要子进程桥接。编排能力大致相当按语言契合度选。
**vs. [Vercel AI SDK](https://github.com/vercel/ai)。** AI SDK 是 LLM 调用层:统一的 TypeScript 客户端,支持 60+ provider带流式、tool calls、结构化输出。它不做多智能体编排。`open-multi-agent` 需要多 agent 时叠在它之上。两者互补:单 agent 用 AI SDK需要团队用这个。
## 谁在用
`open-multi-agent` 是一个新项目2026-04-01 发布MIT 许可5,500+ stars。生态还在成形下面这份列表很短但都真实
- **[temodar-agent](https://github.com/xeloxa/temodar-agent)**(约 50 stars。WordPress 安全分析平台,作者 [Ali Sünbül](https://github.com/xeloxa)。在 Docker runtime 里直接使用我们的内置工具(`bash`、`file_*`、`grep`)。已确认生产环境使用。
- **[rentech-quant-platform](https://github.com/rookiecoderasz/rentech-quant-platform)。** 多智能体量化交易研究平台5 条管线 + MCP 集成,基于 `open-multi-agent` 构建。早期信号,项目非常新。
- **家用服务器 Cybersecurity SOC。** 本地完全离线运行 Qwen 2.5 + DeepSeek Coder通过 Ollama在 Wazuh + Proxmox 上构建自主 SOC 流水线。早期用户,未公开。
你在生产环境或 side project 里用 `open-multi-agent` 吗?[开一个 Discussion](https://github.com/JackChen-me/open-multi-agent/discussions),我们会把你列上来。
## 快速开始
@ -54,19 +88,8 @@ const architect: AgentConfig = {
tools: ['file_write'],
}
const developer: AgentConfig = {
name: 'developer',
model: 'claude-sonnet-4-6',
systemPrompt: 'You implement what the architect designs.',
tools: ['bash', 'file_read', 'file_write', 'file_edit'],
}
const reviewer: AgentConfig = {
name: 'reviewer',
model: 'claude-sonnet-4-6',
systemPrompt: 'You review code for correctness and clarity.',
tools: ['file_read', 'grep'],
}
const developer: AgentConfig = { /* 同样结构tools: ['bash', 'file_read', 'file_write', 'file_edit'] */ }
const reviewer: AgentConfig = { /* 同样结构tools: ['file_read', 'grep'] */ }
const orchestrator = new OpenMultiAgent({
defaultModel: 'claude-sonnet-4-6',
@ -82,8 +105,8 @@ const team = orchestrator.createTeam('api-team', {
// 描述一个目标——框架将其拆解为任务并编排执行
const result = await orchestrator.runTeam(team, 'Create a REST API for a todo list in /tmp/todo-api/')
console.log(`成功: ${result.success}`)
console.log(`Token 用量: ${result.totalTokenUsage.output_tokens} output tokens`)
console.log(`Success: ${result.success}`)
console.log(`Tokens: ${result.totalTokenUsage.output_tokens} output tokens`)
```
执行过程:
@ -95,8 +118,8 @@ task_complete architect
task_start developer
task_start developer // 无依赖的任务并行执行
task_complete developer
task_start reviewer // 实现完成后自动解锁
task_complete developer
task_start reviewer // 实现完成后自动解锁
task_complete reviewer
agent_complete coordinator // 综合所有结果
Success: true
@ -111,29 +134,18 @@ Tokens: 12847 output tokens
| 自动编排团队 | `runTeam()` | 给一个目标,框架自动规划和执行 |
| 显式任务管线 | `runTasks()` | 你自己定义任务图和分配 |
如果需要 MapReduce 风格的扇出而不涉及任务依赖,直接使用 `AgentPool.runParallel()`。参见[示例 07](examples/07-fan-out-aggregate.ts)。
## 示例
所有示例都是可运行脚本,位于 [`examples/`](./examples/) 目录。使用 `npx tsx` 运行
[`examples/`](./examples/) 里有 15 个可运行脚本。推荐从这 4 个开始
```bash
npx tsx examples/01-single-agent.ts
```
- [02 — 团队协作](examples/02-team-collaboration.ts)`runTeam()` 协调者模式。
- [06 — 本地模型](examples/06-local-model.ts):通过 `baseURL` 把 Ollama 和 Claude 放在同一条管线。
- [09 — 结构化输出](examples/09-structured-output.ts):任意 agent 产出 Zod 校验过的 JSON。
- [11 — 可观测性](examples/11-trace-observability.ts)`onTrace` 回调,为 LLM 调用、工具、任务发出结构化 span。
| 示例 | 展示内容 |
|------|----------|
| [01 — 单智能体](examples/01-single-agent.ts) | `runAgent()` 单次调用、`stream()` 流式输出、`prompt()` 多轮对话 |
| [02 — 团队协作](examples/02-team-collaboration.ts) | `runTeam()` 自动编排 + 协调者模式 |
| [03 — 任务流水线](examples/03-task-pipeline.ts) | `runTasks()` 显式依赖图(设计 → 实现 → 测试 + 评审) |
| [04 — 多模型团队](examples/04-multi-model-team.ts) | `defineTool()` 自定义工具、Anthropic + OpenAI 混合、`AgentPool` |
| [05 — Copilot](examples/05-copilot-test.ts) | GitHub Copilot 作为 LLM 提供者 |
| [06 — 本地模型](examples/06-local-model.ts) | Ollama + Claude 混合流水线,通过 `baseURL` 接入(兼容 vLLM、LM Studio 等) |
| [07 — 扇出聚合](examples/07-fan-out-aggregate.ts) | `runParallel()` MapReduce — 3 个分析师并行,然后综合 |
| [08 — Gemma 4 本地](examples/08-gemma4-local.ts) | `runTasks()` + `runTeam()` 本地 Gemma 4 via Ollama — 零 API 费用 |
| [09 — 结构化输出](examples/09-structured-output.ts) | `outputSchema`Zod— 校验 JSON 输出,通过 `result.structured` 获取 |
| [10 — 任务重试](examples/10-task-retry.ts) | `maxRetries` / `retryDelayMs` / `retryBackoff` + `task_retry` 进度事件 |
| [11 — 可观测性](examples/11-trace-observability.ts) | `onTrace` 回调 — LLM 调用、工具、任务、智能体的结构化 span 事件 |
| [12 — Grok](examples/12-grok.ts) | 同示例 02`runTeam()` 团队协作),使用 Grok`XAI_API_KEY` |
| [13 — Gemini](examples/13-gemini.ts) | Gemini 适配器测试,使用 `gemini-2.5-flash``GEMINI_API_KEY` |
`npx tsx examples/02-team-collaboration.ts` 运行任意一个。
## 架构
@ -188,6 +200,54 @@ npx tsx examples/01-single-agent.ts
| `file_edit` | 通过精确字符串匹配编辑文件。 |
| `grep` | 使用正则表达式搜索文件内容。优先使用 ripgrep回退到 Node.js 实现。 |
## 工具配置
可以通过预设、白名单和黑名单对 agent 的工具访问进行精细控制。
### 工具预设
为常见场景预定义的工具组合:
```typescript
const readonlyAgent: AgentConfig = {
name: 'reader',
model: 'claude-sonnet-4-6',
toolPreset: 'readonly', // file_read, grep, glob
}
const readwriteAgent: AgentConfig = {
name: 'editor',
model: 'claude-sonnet-4-6',
toolPreset: 'readwrite', // file_read, file_write, file_edit, grep, glob
}
const fullAgent: AgentConfig = {
name: 'executor',
model: 'claude-sonnet-4-6',
toolPreset: 'full', // file_read, file_write, file_edit, grep, glob, bash
}
```
### 高级过滤
将预设与白名单、黑名单组合,实现精确控制:
```typescript
const customAgent: AgentConfig = {
name: 'custom',
model: 'claude-sonnet-4-6',
toolPreset: 'readwrite', // 起点file_read, file_write, file_edit, grep, glob
tools: ['file_read', 'grep'], // 白名单:与预设取交集 = file_read, grep
disallowedTools: ['grep'], // 黑名单:再减去 = 只剩 file_read
}
```
**解析顺序:** preset → allowlist → denylist → 框架安全护栏。
### 自定义工具
通过 `agent.addTool()` 添加的工具始终可用,不受过滤规则影响。
## 支持的 Provider
| Provider | 配置 | 环境变量 | 状态 |
@ -200,6 +260,8 @@ npx tsx examples/01-single-agent.ts
| Ollama / vLLM / LM Studio | `provider: 'openai'` + `baseURL` | — | 已验证 |
| llama.cpp server | `provider: 'openai'` + `baseURL` | — | 已验证 |
Gemini 需要 `npm install @google/genai`optional peer dependency
已验证支持 tool-calling 的本地模型:**Gemma 4**(见[示例 08](examples/08-gemma4-local.ts))。
任何 OpenAI 兼容 API 均可通过 `provider: 'openai'` + `baseURL` 接入DeepSeek、Groq、Mistral、Qwen、MiniMax 等)。**Grok 现已原生支持**,使用 `provider: 'grok'`
@ -248,27 +310,22 @@ const grokAgent: AgentConfig = {
欢迎提 Issue、功能需求和 PR。以下方向的贡献尤其有价值
- **Provider 集成** — 验证并文档化 OpenAI 兼容 ProviderDeepSeek、Groq、Qwen、MiniMax 等)通过 `baseURL` 接入。详见 [#25](https://github.com/JackChen-me/open-multi-agent/issues/25)。对于非 OpenAI 兼容的 Provider欢迎贡献新的 `LLMAdapter` 实现——接口只需两个方法:`chat()` 和 `stream()`
- **示例** — 真实场景的工作流和用例。
- **文档** — 指南、教程和 API 文档。
## 作者
> JackChen — 前 WPS 产品经理,现独立创业者。关注小红书[「杰克西|硅基杠杆」](https://www.xiaohongshu.com/user/profile/5a1bdc1e4eacab4aa39ea6d6),持续获取我的 AI Agent 观点和思考。
## 贡献者
<a href="https://github.com/JackChen-me/open-multi-agent/graphs/contributors">
<img src="https://contrib.rocks/image?repo=JackChen-me/open-multi-agent&v=20260408" />
<img src="https://contrib.rocks/image?repo=JackChen-me/open-multi-agent&max=20&v=20260411" />
</a>
## Star 趋势
<a href="https://star-history.com/#JackChen-me/open-multi-agent&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date&theme=dark&v=20260408" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date&v=20260408" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date&v=20260408" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date" />
</picture>
</a>

View File

@ -4,6 +4,8 @@
* Demonstrates how to define tasks with explicit dependency chains
* (design implement test review) using runTasks(). The TaskQueue
* automatically blocks downstream tasks until their dependencies complete.
* Prompt context is dependency-scoped by default: each task sees only its own
* description plus direct dependency results (not unrelated team outputs).
*
* Run:
* npx tsx examples/03-task-pipeline.ts
@ -116,6 +118,7 @@ const tasks: Array<{
description: string
assignee?: string
dependsOn?: string[]
memoryScope?: 'dependencies' | 'all'
}> = [
{
title: 'Design: URL shortener data model',
@ -162,6 +165,9 @@ Produce a structured code review with sections:
- Verdict: SHIP or NEEDS WORK`,
assignee: 'reviewer',
dependsOn: ['Implement: URL shortener'], // runs in parallel with Test after Implement completes
// Optional override: reviewers can opt into full shared memory when needed.
// Remove this line to keep strict dependency-only context.
memoryScope: 'all',
},
]

View File

@ -8,7 +8,7 @@
* npx tsx examples/16-mcp-github.ts
*
* Prerequisites:
* - ANTHROPIC_API_KEY
* - GEMINI_API_KEY
* - GITHUB_TOKEN
* - @modelcontextprotocol/sdk installed
*/
@ -16,6 +16,11 @@
import { Agent, ToolExecutor, ToolRegistry, registerBuiltInTools } from '../src/index.js'
import { connectMCPTools } from '../src/mcp.js'
if (!process.env.GITHUB_TOKEN?.trim()) {
console.error('Missing GITHUB_TOKEN: set a GitHub personal access token in the environment.')
process.exit(1)
}
const { tools, disconnect } = await connectMCPTools({
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-github'],
@ -34,7 +39,8 @@ const executor = new ToolExecutor(registry)
const agent = new Agent(
{
name: 'github-agent',
model: 'claude-sonnet-4-6',
model: 'gemini-2.5-flash',
provider: 'gemini',
tools: tools.map((tool) => tool.name),
systemPrompt: 'Use GitHub MCP tools to answer repository questions.',
},

View File

@ -1,6 +1,6 @@
{
"name": "@jackchen_me/open-multi-agent",
"version": "1.0.1",
"version": "1.1.0",
"description": "TypeScript multi-agent framework — one runTeam() call from goal to result. Auto task decomposition, parallel execution. 3 dependencies, deploys anywhere Node.js runs.",
"files": [
"dist",

View File

@ -124,8 +124,18 @@ export class SharedMemory {
* - plan: Implement feature X using const type params
* ```
*/
async getSummary(): Promise<string> {
const all = await this.store.list()
async getSummary(filter?: { taskIds?: string[] }): Promise<string> {
let all = await this.store.list()
if (filter?.taskIds && filter.taskIds.length > 0) {
const taskIds = new Set(filter.taskIds)
all = all.filter((entry) => {
const slashIdx = entry.key.indexOf('/')
const localKey = slashIdx === -1 ? entry.key : entry.key.slice(slashIdx + 1)
if (!localKey.startsWith('task:') || !localKey.endsWith(':result')) return false
const taskId = localKey.slice('task:'.length, localKey.length - ':result'.length)
return taskIds.has(taskId)
})
}
if (all.length === 0) return ''
// Group entries by agent name.

View File

@ -324,6 +324,10 @@ interface ParsedTaskSpec {
description: string
assignee?: string
dependsOn?: string[]
memoryScope?: 'dependencies' | 'all'
maxRetries?: number
retryDelayMs?: number
retryBackoff?: number
}
/**
@ -362,6 +366,10 @@ function parseTaskSpecs(raw: string): ParsedTaskSpec[] | null {
dependsOn: Array.isArray(obj['dependsOn'])
? (obj['dependsOn'] as unknown[]).filter((x): x is string => typeof x === 'string')
: undefined,
memoryScope: obj['memoryScope'] === 'all' ? 'all' : undefined,
maxRetries: typeof obj['maxRetries'] === 'number' ? obj['maxRetries'] : undefined,
retryDelayMs: typeof obj['retryDelayMs'] === 'number' ? obj['retryDelayMs'] : undefined,
retryBackoff: typeof obj['retryBackoff'] === 'number' ? obj['retryBackoff'] : undefined,
})
}
@ -492,8 +500,8 @@ async function executeQueue(
data: task,
} satisfies OrchestratorEvent)
// Build the prompt: inject shared memory context + task description
const prompt = await buildTaskPrompt(task, team)
// Build the prompt: task description + dependency-only context by default.
const prompt = await buildTaskPrompt(task, team, queue)
// Build trace context for this task's agent run
const traceOptions: Partial<RunOptions> | undefined = config.onTrace
@ -626,22 +634,37 @@ async function executeQueue(
*
* Injects:
* - Task title and description
* - Dependency results from shared memory (if available)
* - Direct dependency task results by default (clean slate when none)
* - Optional full shared-memory context when `task.memoryScope === 'all'`
* - Any messages addressed to this agent from the team bus
*/
async function buildTaskPrompt(task: Task, team: Team): Promise<string> {
async function buildTaskPrompt(task: Task, team: Team, queue: TaskQueue): Promise<string> {
const lines: string[] = [
`# Task: ${task.title}`,
'',
task.description,
]
// Inject shared memory summary so the agent sees its teammates' work
const sharedMem = team.getSharedMemoryInstance()
if (sharedMem) {
const summary = await sharedMem.getSummary()
if (summary) {
lines.push('', summary)
if (task.memoryScope === 'all') {
// Explicit opt-in for full visibility (legacy/shared-memory behavior).
const sharedMem = team.getSharedMemoryInstance()
if (sharedMem) {
const summary = await sharedMem.getSummary()
if (summary) {
lines.push('', summary)
}
}
} else if (task.dependsOn && task.dependsOn.length > 0) {
// Default-deny: inject only explicit prerequisite outputs.
const depResults: string[] = []
for (const depId of task.dependsOn) {
const depTask = queue.get(depId)
if (depTask?.status === 'completed' && depTask.result) {
depResults.push(`### ${depTask.title} (by ${depTask.assignee ?? 'unknown'})\n${depTask.result}`)
}
}
if (depResults.length > 0) {
lines.push('', '## Context from prerequisite tasks', '', ...depResults)
}
}
@ -1071,6 +1094,7 @@ export class OpenMultiAgent {
description: string
assignee?: string
dependsOn?: string[]
memoryScope?: 'dependencies' | 'all'
maxRetries?: number
retryDelayMs?: number
retryBackoff?: number
@ -1087,6 +1111,7 @@ export class OpenMultiAgent {
description: t.description,
assignee: t.assignee,
dependsOn: t.dependsOn,
memoryScope: t.memoryScope,
maxRetries: t.maxRetries,
retryDelayMs: t.retryDelayMs,
retryBackoff: t.retryBackoff,
@ -1308,6 +1333,7 @@ export class OpenMultiAgent {
*/
private loadSpecsIntoQueue(
specs: ReadonlyArray<ParsedTaskSpec & {
memoryScope?: 'dependencies' | 'all'
maxRetries?: number
retryDelayMs?: number
retryBackoff?: number
@ -1328,6 +1354,7 @@ export class OpenMultiAgent {
assignee: spec.assignee && agentNames.has(spec.assignee)
? spec.assignee
: undefined,
memoryScope: spec.memoryScope,
maxRetries: spec.maxRetries,
retryDelayMs: spec.retryDelayMs,
retryBackoff: spec.retryBackoff,

View File

@ -289,6 +289,11 @@ export class TaskQueue {
return this.list().filter((t) => t.status === status)
}
/** Returns a task by ID, if present. */
get(taskId: string): Task | undefined {
return this.tasks.get(taskId)
}
/**
* Returns `true` when every task in the queue has reached a terminal state
* (`'completed'`, `'failed'`, or `'skipped'`), **or** the queue is empty.

View File

@ -31,6 +31,7 @@ export function createTask(input: {
description: string
assignee?: string
dependsOn?: string[]
memoryScope?: 'dependencies' | 'all'
maxRetries?: number
retryDelayMs?: number
retryBackoff?: number
@ -43,6 +44,7 @@ export function createTask(input: {
status: 'pending' as TaskStatus,
assignee: input.assignee,
dependsOn: input.dependsOn ? [...input.dependsOn] : undefined,
memoryScope: input.memoryScope,
result: undefined,
createdAt: now,
updatedAt: now,

View File

@ -72,12 +72,19 @@ export function defineTool<TInput>(config: {
name: string
description: string
inputSchema: ZodSchema<TInput>
/**
* Optional JSON Schema for the LLM (bypasses Zod JSON Schema conversion).
*/
llmInputSchema?: Record<string, unknown>
execute: (input: TInput, context: ToolUseContext) => Promise<ToolResult>
}): ToolDefinition<TInput> {
return {
name: config.name,
description: config.description,
inputSchema: config.inputSchema,
...(config.llmInputSchema !== undefined
? { llmInputSchema: config.llmInputSchema }
: {}),
execute: config.execute,
}
}
@ -169,7 +176,8 @@ export class ToolRegistry {
*/
toToolDefs(): LLMToolDef[] {
return Array.from(this.tools.values()).map((tool) => {
const schema = zodToJsonSchema(tool.inputSchema)
const schema =
tool.llmInputSchema ?? zodToJsonSchema(tool.inputSchema)
return {
name: tool.name,
description: tool.description,
@ -194,13 +202,20 @@ export class ToolRegistry {
toLLMTools(): Array<{
name: string
description: string
input_schema: {
type: 'object'
properties: Record<string, JSONSchemaProperty>
required?: string[]
}
/** Anthropic-style tool input JSON Schema (`type` is usually `object`). */
input_schema: Record<string, unknown>
}> {
return Array.from(this.tools.values()).map((tool) => {
if (tool.llmInputSchema !== undefined) {
return {
name: tool.name,
description: tool.description,
input_schema: {
type: 'object' as const,
...(tool.llmInputSchema as Record<string, unknown>),
},
}
}
const schema = zodToJsonSchema(tool.inputSchema)
return {
name: tool.name,

View File

@ -5,22 +5,33 @@ import type { ToolDefinition } from '../types.js'
interface MCPToolDescriptor {
name: string
description?: string
/** MCP tool JSON Schema; same shape LLM APIs expect for object parameters. */
inputSchema?: Record<string, unknown>
}
interface MCPListToolsResponse {
tools?: MCPToolDescriptor[]
nextCursor?: string
}
interface MCPCallToolResponse {
content?: Array<{ type?: string; text?: string }>
content?: Array<Record<string, unknown>>
structuredContent?: unknown
isError?: boolean
toolResult?: unknown
}
interface MCPClientLike {
connect(transport: unknown): Promise<void>
listTools(): Promise<MCPListToolsResponse>
callTool(request: { name: string; arguments: Record<string, unknown> }): Promise<MCPCallToolResponse>
connect(transport: unknown, options?: { timeout?: number; signal?: AbortSignal }): Promise<void>
listTools(
params?: { cursor?: string },
options?: { timeout?: number; signal?: AbortSignal },
): Promise<MCPListToolsResponse>
callTool(
request: { name: string; arguments: Record<string, unknown> },
resultSchema?: unknown,
options?: { timeout?: number; signal?: AbortSignal },
): Promise<MCPCallToolResponse>
close?: () => Promise<void>
}
@ -41,6 +52,8 @@ interface MCPModules {
StdioClientTransport: StdioTransportConstructor
}
const DEFAULT_MCP_REQUEST_TIMEOUT_MS = 60_000
async function loadMCPModules(): Promise<MCPModules> {
const [{ Client }, { StdioClientTransport }] = await Promise.all([
import('@modelcontextprotocol/sdk/client/index.js') as Promise<{
@ -59,10 +72,14 @@ export interface ConnectMCPToolsConfig {
env?: Record<string, string | undefined>
cwd?: string
/**
* Optional prefix used when generating framework tool names.
* Example: "github" -> "github/search_issues"
* Optional segment prepended to MCP tool names for the framework tool (and LLM) name.
* Example: prefix `github` + MCP tool `search_issues` `github_search_issues`.
*/
namePrefix?: string
/**
* Timeout (ms) for MCP connect and each `tools/list` page. Defaults to 60000.
*/
requestTimeoutMs?: number
/**
* Client metadata sent to the MCP server.
*/
@ -75,20 +92,100 @@ export interface ConnectedMCPTools {
disconnect: () => Promise<void>
}
/**
* Build an LLM-safe tool name: MCP and prior examples used `prefix/name`, but
* Anthropic and other providers reject `/` in tool names.
*/
function normalizeToolName(rawName: string, namePrefix?: string): string {
if (namePrefix === undefined || namePrefix.trim() === '') {
return rawName
const trimmedPrefix = namePrefix?.trim()
const base =
trimmedPrefix !== undefined && trimmedPrefix !== ''
? `${trimmedPrefix}_${rawName}`
: rawName
return base.replace(/\//g, '_')
}
/** MCP `tools/list` JSON Schema; forwarded to the LLM as-is (runtime validation stays `z.any()`). */
function mcpLlmInputSchema(
schema: Record<string, unknown> | undefined,
): Record<string, unknown> {
if (schema !== undefined && typeof schema === 'object' && !Array.isArray(schema)) {
return schema
}
return `${namePrefix}/${rawName}`
return { type: 'object' }
}
function contentBlockToText(block: Record<string, unknown>): string | undefined {
const typ = block.type
if (typ === 'text' && typeof block.text === 'string') {
return block.text
}
if (typ === 'image' && typeof block.data === 'string') {
const mime =
typeof block.mimeType === 'string' ? block.mimeType : 'image/*'
return `[image ${mime}; base64 length=${block.data.length}]`
}
if (typ === 'audio' && typeof block.data === 'string') {
const mime =
typeof block.mimeType === 'string' ? block.mimeType : 'audio/*'
return `[audio ${mime}; base64 length=${block.data.length}]`
}
if (
typ === 'resource' &&
block.resource !== null &&
typeof block.resource === 'object'
) {
const r = block.resource as Record<string, unknown>
const uri = typeof r.uri === 'string' ? r.uri : ''
if (typeof r.text === 'string') {
return `[resource ${uri}]\n${r.text}`
}
if (typeof r.blob === 'string') {
const mime = typeof r.mimeType === 'string' ? r.mimeType : ''
return `[resource ${uri}; mimeType=${mime}; blob base64 length=${r.blob.length}]`
}
return `[resource ${uri}]`
}
if (typ === 'resource_link') {
const uri = typeof block.uri === 'string' ? block.uri : ''
const name = typeof block.name === 'string' ? block.name : ''
const desc =
typeof block.description === 'string' ? block.description : ''
const head = `[resource_link name=${JSON.stringify(name)} uri=${JSON.stringify(uri)}]`
return desc === '' ? head : `${head}\n${desc}`
}
return undefined
}
function toToolResultData(result: MCPCallToolResponse): string {
const textBlocks = (result.content ?? [])
.filter((block) => block.type === 'text' && typeof block.text === 'string')
.map((block) => block.text as string)
if ('toolResult' in result && result.toolResult !== undefined) {
try {
return JSON.stringify(result.toolResult, null, 2)
} catch {
return String(result.toolResult)
}
}
if (textBlocks.length > 0) {
return textBlocks.join('\n')
const lines: string[] = []
for (const block of result.content ?? []) {
if (block === null || typeof block !== 'object') continue
const rec = block as Record<string, unknown>
const line = contentBlockToText(rec)
if (line !== undefined) {
lines.push(line)
continue
}
try {
lines.push(
`[${String(rec.type ?? 'unknown')}]\n${JSON.stringify(rec, null, 2)}`,
)
} catch {
lines.push('[mcp content block]')
}
}
if (lines.length > 0) {
return lines.join('\n')
}
if (result.structuredContent !== undefined) {
@ -106,6 +203,26 @@ function toToolResultData(result: MCPCallToolResponse): string {
}
}
async function listAllMcpTools(
client: MCPClientLike,
requestOpts: { timeout: number },
): Promise<MCPToolDescriptor[]> {
const acc: MCPToolDescriptor[] = []
let cursor: string | undefined
do {
const page = await client.listTools(
cursor !== undefined ? { cursor } : {},
requestOpts,
)
acc.push(...(page.tools ?? []))
cursor =
typeof page.nextCursor === 'string' && page.nextCursor !== ''
? page.nextCursor
: undefined
} while (cursor !== undefined)
return acc
}
/**
* Connect to an MCP server over stdio and convert exposed MCP tools into
* open-multi-agent ToolDefinitions.
@ -130,23 +247,30 @@ export async function connectMCPTools(
{ capabilities: {} },
)
await client.connect(transport)
const requestOpts = {
timeout: config.requestTimeoutMs ?? DEFAULT_MCP_REQUEST_TIMEOUT_MS,
}
const listed = await client.listTools()
const mcpTools = listed.tools ?? []
await client.connect(transport, requestOpts)
const mcpTools = await listAllMcpTools(client, requestOpts)
const tools: ToolDefinition[] = mcpTools.map((tool) =>
defineTool({
name: normalizeToolName(tool.name, config.namePrefix),
description: tool.description ?? `MCP tool: ${tool.name}`,
// MCP servers validate arguments internally.
inputSchema: z.any(),
llmInputSchema: mcpLlmInputSchema(tool.inputSchema),
execute: async (input: Record<string, unknown>) => {
try {
const result = await client.callTool({
name: tool.name,
arguments: input,
})
const result = await client.callTool(
{
name: tool.name,
arguments: input,
},
undefined,
requestOpts,
)
return {
data: toToolResultData(result),
isError: result.isError === true,
@ -167,7 +291,6 @@ export async function connectMCPTools(
tools,
disconnect: async () => {
await client.close?.()
await transport.close?.()
},
}
}

View File

@ -170,12 +170,18 @@ export interface ToolResult {
* A tool registered with the framework.
*
* `inputSchema` is a Zod schema used for validation before `execute` is called.
* At API call time it is converted to JSON Schema via {@link LLMToolDef}.
* At API call time it is converted to JSON Schema for {@link LLMToolDef}, unless
* `llmInputSchema` is set (e.g. MCP tools ship JSON Schema from the server).
*/
export interface ToolDefinition<TInput = Record<string, unknown>> {
readonly name: string
readonly description: string
readonly inputSchema: ZodSchema<TInput>
/**
* When present, used as {@link LLMToolDef.inputSchema} as-is instead of
* deriving JSON Schema from `inputSchema` (Zod).
*/
readonly llmInputSchema?: Record<string, unknown>
execute(input: TInput, context: ToolUseContext): Promise<ToolResult>
}
@ -355,6 +361,12 @@ export interface Task {
assignee?: string
/** IDs of tasks that must complete before this one can start. */
dependsOn?: readonly string[]
/**
* Controls what prior team context is injected into this task's prompt.
* - `dependencies` (default): only direct dependency task results
* - `all`: full shared-memory summary
*/
readonly memoryScope?: 'dependencies' | 'all'
result?: string
readonly createdAt: Date
updatedAt: Date

View File

@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import type { ToolUseContext } from '../src/types.js'
import { ToolRegistry } from '../src/tool/framework.js'
const listToolsMock = vi.fn()
const callToolMock = vi.fn()
@ -8,20 +9,38 @@ const clientCloseMock = vi.fn()
const transportCloseMock = vi.fn()
class MockClient {
async connect(transport: unknown): Promise<void> {
async connect(
transport: unknown,
_options?: { timeout?: number },
): Promise<void> {
connectMock(transport)
}
async listTools(): Promise<{ tools: Array<{ name: string; description: string }> }> {
return listToolsMock()
async listTools(
params?: { cursor?: string },
options?: { timeout?: number },
): Promise<{
tools: Array<{
name: string
description: string
inputSchema?: Record<string, unknown>
}>
nextCursor?: string
}> {
return listToolsMock(params, options)
}
async callTool(request: { name: string; arguments: Record<string, unknown> }): Promise<{
content?: Array<{ type: string; text: string }>
async callTool(
request: { name: string; arguments: Record<string, unknown> },
resultSchema?: unknown,
options?: { timeout?: number },
): Promise<{
content?: Array<Record<string, unknown>>
structuredContent?: unknown
isError?: boolean
toolResult?: unknown
}> {
return callToolMock(request)
return callToolMock(request, resultSchema, options)
}
async close(): Promise<void> {
@ -60,7 +79,17 @@ beforeEach(() => {
describe('connectMCPTools', () => {
it('connects, discovers tools, and executes MCP calls', async () => {
listToolsMock.mockResolvedValue({
tools: [{ name: 'search_issues', description: 'Search repository issues.' }],
tools: [
{
name: 'search_issues',
description: 'Search repository issues.',
inputSchema: {
type: 'object',
properties: { q: { type: 'string' } },
required: ['q'],
},
},
],
})
callToolMock.mockResolvedValue({
content: [{ type: 'text', text: 'found 2 issues' }],
@ -77,24 +106,92 @@ describe('connectMCPTools', () => {
expect(connectMock).toHaveBeenCalledTimes(1)
expect(connected.tools).toHaveLength(1)
expect(connected.tools[0].name).toBe('github/search_issues')
expect(connected.tools[0].name).toBe('github_search_issues')
const registry = new ToolRegistry()
registry.register(connected.tools[0])
const defs = registry.toToolDefs()
expect(defs[0].inputSchema).toMatchObject({
type: 'object',
properties: { q: { type: 'string' } },
required: ['q'],
})
const result = await connected.tools[0].execute({ q: 'bug' }, context)
expect(callToolMock).toHaveBeenCalledWith({
name: 'search_issues',
arguments: { q: 'bug' },
})
expect(callToolMock).toHaveBeenCalledWith(
{
name: 'search_issues',
arguments: { q: 'bug' },
},
undefined,
expect.objectContaining({ timeout: expect.any(Number) }),
)
expect(result.isError).toBe(false)
expect(result.data).toContain('found 2 issues')
await connected.disconnect()
expect(clientCloseMock).toHaveBeenCalledTimes(1)
expect(transportCloseMock).toHaveBeenCalledTimes(1)
expect(transportCloseMock).not.toHaveBeenCalled()
})
it('aggregates paginated listTools results', async () => {
listToolsMock.mockImplementation(
async (params?: { cursor?: string }) => {
if (params?.cursor === 'c1') {
return {
tools: [
{ name: 'b', description: 'B', inputSchema: { type: 'object' } },
],
}
}
return {
tools: [
{ name: 'a', description: 'A', inputSchema: { type: 'object' } },
],
nextCursor: 'c1',
}
},
)
callToolMock.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] })
const { connectMCPTools } = await import('../src/tool/mcp.js')
const connected = await connectMCPTools({
command: 'npx',
args: ['-y', 'mock-mcp-server'],
})
expect(listToolsMock).toHaveBeenCalledTimes(2)
expect(listToolsMock.mock.calls[1][0]).toEqual({ cursor: 'c1' })
expect(connected.tools).toHaveLength(2)
expect(connected.tools.map((t) => t.name)).toEqual(['a', 'b'])
})
it('serializes non-text MCP content blocks', async () => {
listToolsMock.mockResolvedValue({
tools: [{ name: 'pic', description: 'Pic', inputSchema: { type: 'object' } }],
})
callToolMock.mockResolvedValue({
content: [
{
type: 'image',
data: 'AAA',
mimeType: 'image/png',
},
],
isError: false,
})
const { connectMCPTools } = await import('../src/tool/mcp.js')
const connected = await connectMCPTools({ command: 'npx', args: ['x'] })
const result = await connected.tools[0].execute({}, context)
expect(result.data).toContain('image')
expect(result.data).toContain('base64 length=3')
})
it('marks tool result as error when MCP returns isError', async () => {
listToolsMock.mockResolvedValue({
tools: [{ name: 'danger', description: 'Dangerous op.' }],
tools: [{ name: 'danger', description: 'Dangerous op.', inputSchema: {} }],
})
callToolMock.mockResolvedValue({
content: [{ type: 'text', text: 'permission denied' }],

View File

@ -43,6 +43,7 @@ function createMockAdapter(responses: string[]): LLMAdapter {
*/
let mockAdapterResponses: string[] = []
let capturedChatOptions: LLMChatOptions[] = []
let capturedPrompts: string[] = []
vi.mock('../src/llm/adapter.js', () => ({
createAdapter: async () => {
@ -51,6 +52,12 @@ vi.mock('../src/llm/adapter.js', () => ({
name: 'mock',
async chat(_msgs: LLMMessage[], options: LLMChatOptions): Promise<LLMResponse> {
capturedChatOptions.push(options)
const lastUser = [..._msgs].reverse().find((m) => m.role === 'user')
const prompt = (lastUser?.content ?? [])
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
.map((b) => b.text)
.join('\n')
capturedPrompts.push(prompt)
const text = mockAdapterResponses[callIndex] ?? 'default mock response'
callIndex++
return {
@ -97,6 +104,7 @@ describe('OpenMultiAgent', () => {
beforeEach(() => {
mockAdapterResponses = []
capturedChatOptions = []
capturedPrompts = []
})
describe('createTeam', () => {
@ -198,6 +206,67 @@ describe('OpenMultiAgent', () => {
expect(result.success).toBe(true)
})
it('uses a clean slate for tasks without dependencies', async () => {
mockAdapterResponses = ['alpha done', 'beta done']
const oma = new OpenMultiAgent({ defaultModel: 'mock-model' })
const team = oma.createTeam('t', teamCfg())
await oma.runTasks(team, [
{ title: 'Independent A', description: 'Do independent A', assignee: 'worker-a' },
{ title: 'Independent B', description: 'Do independent B', assignee: 'worker-b' },
])
const workerPrompts = capturedPrompts.slice(0, 2)
expect(workerPrompts[0]).toContain('# Task: Independent A')
expect(workerPrompts[1]).toContain('# Task: Independent B')
expect(workerPrompts[0]).not.toContain('## Shared Team Memory')
expect(workerPrompts[1]).not.toContain('## Shared Team Memory')
expect(workerPrompts[0]).not.toContain('## Context from prerequisite tasks')
expect(workerPrompts[1]).not.toContain('## Context from prerequisite tasks')
})
it('injects only dependency results into dependent task prompts', async () => {
mockAdapterResponses = ['first output', 'second output']
const oma = new OpenMultiAgent({ defaultModel: 'mock-model' })
const team = oma.createTeam('t', teamCfg())
await oma.runTasks(team, [
{ title: 'First', description: 'Produce first', assignee: 'worker-a' },
{ title: 'Second', description: 'Use first', assignee: 'worker-b', dependsOn: ['First'] },
])
const secondPrompt = capturedPrompts[1] ?? ''
expect(secondPrompt).toContain('## Context from prerequisite tasks')
expect(secondPrompt).toContain('### First (by worker-a)')
expect(secondPrompt).toContain('first output')
expect(secondPrompt).not.toContain('## Shared Team Memory')
})
it('supports memoryScope all opt-in for full shared memory visibility', async () => {
mockAdapterResponses = ['writer output', 'reader output']
const oma = new OpenMultiAgent({ defaultModel: 'mock-model' })
const team = oma.createTeam('t', teamCfg())
await oma.runTasks(team, [
{ title: 'Write', description: 'Write something', assignee: 'worker-a' },
{
title: 'Read all',
description: 'Read everything',
assignee: 'worker-b',
memoryScope: 'all',
dependsOn: ['Write'],
},
])
const secondPrompt = capturedPrompts[1] ?? ''
expect(secondPrompt).toContain('## Shared Team Memory')
expect(secondPrompt).toContain('task:')
expect(secondPrompt).not.toContain('## Context from prerequisite tasks')
})
})
describe('runTeam', () => {

View File

@ -107,6 +107,19 @@ describe('SharedMemory', () => {
expect(summary).toContain('…')
})
it('filters summary to only requested task IDs', async () => {
const mem = new SharedMemory()
await mem.write('alice', 'task:t1:result', 'output 1')
await mem.write('bob', 'task:t2:result', 'output 2')
await mem.write('alice', 'notes', 'not a task result')
const summary = await mem.getSummary({ taskIds: ['t2'] })
expect(summary).toContain('### bob')
expect(summary).toContain('task:t2:result: output 2')
expect(summary).not.toContain('task:t1:result: output 1')
expect(summary).not.toContain('notes: not a task result')
})
// -------------------------------------------------------------------------
// listAll
// -------------------------------------------------------------------------

View File

@ -27,6 +27,7 @@ describe('TaskQueue', () => {
q.add(task('a'))
expect(q.list()).toHaveLength(1)
expect(q.list()[0].id).toBe('a')
expect(q.get('a')?.title).toBe('a')
})
it('fires task:ready for a task with no dependencies', () => {