From f5de0811330a170321d0c45cfecfb5233dfec466 Mon Sep 17 00:00:00 2001 From: Achuth Karakkat Date: Thu, 19 Feb 2026 05:59:15 +0530 Subject: [PATCH 1/6] feat: add mission control-inspired multi-agent features - Activity Feed: surface lifecycle events (agent spawns/completions/errors) in the Control UI agents tab via real-time WebSocket stream - SOUL.md Editor: dedicated Soul tab for focused agent persona editing - Squad Builder: designer agent example that creates agents conversationally via config.patch and agents_files_set - Task Persistence: file-based task store with agent tool + gateway RPCs (tasks.list, tasks.get, tasks.create, tasks.update) --- CLAUDE.md | 131 ++++++++++++++++- docs/tools/slash-commands.md | 1 + examples/multi-agent-poc/README.md | 111 +++++++++++++++ examples/multi-agent-poc/config.yaml | 70 +++++++++ .../workspaces/coder/IDENTITY.md | 6 + .../multi-agent-poc/workspaces/coder/SOUL.md | 35 +++++ .../workspaces/designer/IDENTITY.md | 6 + .../workspaces/designer/SOUL.md | 59 ++++++++ .../workspaces/researcher/IDENTITY.md | 6 + .../workspaces/researcher/SOUL.md | 34 +++++ .../workspaces/router/IDENTITY.md | 6 + .../multi-agent-poc/workspaces/router/SOUL.md | 35 +++++ .../workspaces/writer/IDENTITY.md | 6 + .../multi-agent-poc/workspaces/writer/SOUL.md | 35 +++++ src/agents/openclaw-tools.ts | 5 + src/agents/tasks-store.ts | 120 ++++++++++++++++ src/agents/tools/tasks-tool.ts | 104 ++++++++++++++ src/auto-reply/commands-registry.data.ts | 28 ++++ src/gateway/protocol/schema/CLAUDE.md | 7 + src/gateway/server-methods.ts | 6 + src/gateway/server-methods/tasks.ts | 134 ++++++++++++++++++ src/slack/monitor/slash.ts | 22 ++- ui/src/styles/chat/layout.css | 13 ++ ui/src/ui/app-render.helpers.ts | 84 ++++++++++- ui/src/ui/app-render.ts | 11 +- ui/src/ui/app-tool-stream.ts | 30 ++++ ui/src/ui/app-view-state.ts | 13 +- ui/src/ui/app.ts | 13 +- ui/src/ui/views/agents-panels-activity.ts | 79 +++++++++++ ui/src/ui/views/agents-panels-soul.ts | 66 +++++++++ ui/src/ui/views/agents.ts | 35 ++++- 31 files changed, 1298 insertions(+), 13 deletions(-) mode change 120000 => 100644 CLAUDE.md create mode 100644 examples/multi-agent-poc/README.md create mode 100644 examples/multi-agent-poc/config.yaml create mode 100644 examples/multi-agent-poc/workspaces/coder/IDENTITY.md create mode 100644 examples/multi-agent-poc/workspaces/coder/SOUL.md create mode 100644 examples/multi-agent-poc/workspaces/designer/IDENTITY.md create mode 100644 examples/multi-agent-poc/workspaces/designer/SOUL.md create mode 100644 examples/multi-agent-poc/workspaces/researcher/IDENTITY.md create mode 100644 examples/multi-agent-poc/workspaces/researcher/SOUL.md create mode 100644 examples/multi-agent-poc/workspaces/router/IDENTITY.md create mode 100644 examples/multi-agent-poc/workspaces/router/SOUL.md create mode 100644 examples/multi-agent-poc/workspaces/writer/IDENTITY.md create mode 100644 examples/multi-agent-poc/workspaces/writer/SOUL.md create mode 100644 src/agents/tasks-store.ts create mode 100644 src/agents/tools/tasks-tool.ts create mode 100644 src/gateway/protocol/schema/CLAUDE.md create mode 100644 src/gateway/server-methods/tasks.ts create mode 100644 ui/src/ui/views/agents-panels-activity.ts create mode 100644 ui/src/ui/views/agents-panels-soul.ts diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index c3170642553f..000000000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000000..c97cf5675679 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,130 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What Is OpenClaw + +Multi-channel AI gateway with extensible messaging integrations. TypeScript (ESM), pnpm monorepo. Connects to WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Matrix, MS Teams, and many more via a plugin/extension system. + +## Essential Commands + +```bash +pnpm install # Install dependencies (Node ≥22.12.0, pnpm 10.x) +pnpm build # Full build (tsdown → dist/) +pnpm check # Format check + tsgo + oxlint (run before commits) +pnpm test # Run all tests (parallel vitest) +pnpm test:fast # Unit tests only (excludes gateway/extensions) +pnpm test:e2e # E2E tests +pnpm test:coverage # Unit tests with V8 coverage +pnpm test:watch # Watch mode +pnpm lint # oxlint --type-aware +pnpm lint:fix # oxlint fix + format +pnpm format # oxfmt --write +pnpm format:check # oxfmt --check +pnpm tsgo # TypeScript type checking +``` + +### Running a Single Test + +```bash +vitest run --config vitest.unit.config.ts src/path/to/file.test.ts +``` + +### Dev Mode + +```bash +pnpm openclaw ... # Run CLI in dev (via tsx) +pnpm dev # Start dev server +pnpm gateway:dev # Gateway dev mode (skips channels) +pnpm ui:dev # Control UI dev (Vite) +pnpm tui:dev # Terminal UI dev +``` + +### Commits + +Use `scripts/committer "" ` instead of manual `git add`/`git commit` to keep staging scoped. Follow concise action-oriented messages (e.g., `CLI: add verbose flag to send`). + +## Architecture + +### Monorepo Layout + +- **`src/`** — Core TypeScript source (CLI, gateway, agents, channels, memory, media, plugins, providers, web) +- **`extensions/`** — Channel plugins as workspace packages (~38: discord, telegram, matrix, msteams, voice-call, etc.) +- **`packages/`** — Sub-packages (clawdbot, moltbot) +- **`ui/`** — Control UI (Vite + Lit with legacy decorators) +- **`apps/`** — Native apps (macOS/Swift, iOS/Swift, Android/Kotlin) +- **`docs/`** — Mintlify-hosted documentation (docs.openclaw.ai) +- **`dist/`** — Built output + +### Key Source Directories (`src/`) + +| Directory | Purpose | +| ------------ | --------------------------------------------------------- | +| `agents/` | Multi-agent routing, Pi agent integration, tools | +| `channels/` | Channel routing, group rules, allowlists | +| `commands/` | CLI commands (gateway, agent, send, wizard, doctor, etc.) | +| `cli/` | CLI wiring and command setup | +| `config/` | Configuration loading, validation, state migrations | +| `gateway/` | WebSocket control plane, server, protocol | +| `memory/` | Vector DB (SQLite-vec), RAG, memory core | +| `media/` | Image/audio/video pipeline, transcription | +| `plugins/` | Plugin runtime, registry, manifest discovery | +| `providers/` | Model providers (Claude, GPT, Gemini, etc.) | +| `routing/` | Message routing logic | +| `security/` | Security checks, DM policies | +| `tui/` | Terminal UI | +| `web/` | WebChat UI, websocket bridge | + +### Extension System + +Extensions live in `extensions/` as separate workspace packages. Plugin-only deps go in the extension's own `package.json`, not root. Runtime deps must be in `dependencies` (npm install runs `--omit=dev` in plugin dir). Avoid `workspace:*` in `dependencies`; put `openclaw` in `devDependencies` or `peerDependencies` instead. + +### Entry Points + +- CLI: `openclaw.mjs` → `dist/entry.js` +- Dev: `node scripts/run-node.mjs` (tsx) +- Gateway: WebSocket control plane started via `openclaw gateway run` +- Plugin SDK: exported via `openclaw/plugin-sdk` + +## Coding Standards + +- **TypeScript ESM only**, strict mode. Avoid `any` types. +- **Formatting/linting:** oxfmt + oxlint (not ESLint/Prettier). Run `pnpm check` before commits. +- **File size:** aim for ~500-700 LOC; split/refactor when it improves clarity. +- **Dependency injection:** use `createDefaultDeps` pattern. +- **Naming:** **OpenClaw** for product/docs headings; `openclaw` for CLI/package/config keys. +- **Comments:** brief comments for tricky/non-obvious logic only. +- **Tool schemas (google-antigravity):** no `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` for string lists, `Type.Optional(...)` instead of `... | null`. +- **CLI progress:** use `src/cli/progress.ts`; don't hand-roll spinners. +- **Patched deps:** any dep with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`). Patching deps requires explicit approval. +- **Never update the Carbon dependency.** +- **Control UI:** uses Lit with legacy decorators (`@state()`, `@property()`). Do not switch to standard decorators. + +## Testing + +- Framework: Vitest with V8 coverage (70% lines/branches/functions/statements thresholds). +- Tests colocated as `*.test.ts`; e2e as `*.e2e.test.ts`. +- Test setup: `test/setup.ts` (isolated home dirs, plugin registry mocking, channel stubs). +- Live tests (real API keys): `OPENCLAW_LIVE_TEST=1 pnpm test:live` +- Do not set test workers above 16. + +## Pre-commit & CI + +- Pre-commit hooks: `prek install` (trailing-whitespace, yaml, large-file detection, secret detection, shellcheck, oxlint, oxfmt, swiftlint). +- CI runs: format check → tsgo → oxlint → unit tests → e2e → Docker integration tests. +- Before pushing: `pnpm build && pnpm check && pnpm test` + +## Multi-Agent Safety + +When working alongside other agents: + +- Do not create/apply/drop `git stash` entries unless explicitly requested. +- Do not switch branches or modify git worktrees unless explicitly requested. +- Scope commits to your changes only; avoid cross-cutting state changes. +- When you see unrecognized files, keep going; focus on your changes. + +## Docs + +- Hosted on Mintlify (docs.openclaw.ai). Internal links: root-relative, no `.md`/`.mdx` suffix. +- `docs/zh-CN/**` is generated; do not edit manually. +- Avoid em dashes and apostrophes in doc headings (breaks Mintlify anchors). diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 081e4933b646..268e10500dca 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -77,6 +77,7 @@ Text + native (when enabled): - `/approve allow-once|allow-always|deny` (resolve exec approval prompts) - `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size) - `/whoami` (show your sender id; alias: `/id`) +- `/agent ` (send a message to a specific agent; in Slack, shows an interactive agent picker) - `/subagents list|kill|log|info|send|steer` (inspect, kill, log, or steer sub-agent runs for the current session) - `/kill ` (immediately abort one or all running sub-agents for this session; no confirmation message) - `/steer ` (steer a running sub-agent immediately: in-run when possible, otherwise abort current work and restart on the steer message) diff --git a/examples/multi-agent-poc/README.md b/examples/multi-agent-poc/README.md new file mode 100644 index 000000000000..7dfb0be22334 --- /dev/null +++ b/examples/multi-agent-poc/README.md @@ -0,0 +1,111 @@ +# Multi-Agent POC + +Demonstrates OpenClaw's multi-agent capability with a router/classifier agent that automatically delegates to specialized agents. + +## Architecture + +``` +User message + | + v +[Router Agent] (Haiku - fast, cheap classifier) + | + +---> [Research Agent] (Sonnet - deep research) + +---> [Writing Agent] (Sonnet - writing/editing) + +---> [Code Agent] (Sonnet - coding/debugging) +``` + +The **Router Agent** receives all messages, classifies intent, and uses `sessions_spawn` to delegate to the right specialist. Users don't need to manually select an agent. + +For manual agent selection, the `/agent` slash command (Slack) or the agent picker dropdown (Control UI) lets you target a specific agent directly. + +## Agents + +| Agent | ID | Emoji | Purpose | Tool Profile | +| ----------- | ------------ | ----- | ------------------------------------- | ------------ | +| Switchboard | `router` | 🔀 | Classify intent, route to specialist | `minimal` | +| Scout | `researcher` | 🔍 | Research, fact-finding, summarization | `full` | +| Quill | `writer` | ✍️ | Writing, editing, communications | `messaging` | +| Forge | `coder` | ⚡ | Code, debugging, reviews | `coding` | +| Architect | `designer` | 🏗️ | Create new agents conversationally | `minimal` | + +## Setup + +1. Copy the agent config into your `~/.openclaw/config.yaml`: + +```bash +# Or merge the agents section manually +cp config.yaml ~/.openclaw/config.yaml +``` + +2. Copy workspace files: + +```bash +cp -r workspaces/* ~/.openclaw/workspaces/ +``` + +3. Start the gateway: + +```bash +pnpm openclaw gateway run +``` + +## Verify + +```bash +# List agents +pnpm openclaw gateway call agents.list + +# Chat with the router (auto-classifies and delegates) +pnpm openclaw agent --message "What is quantum computing?" +# -> Router spawns Research Agent + +pnpm openclaw agent --message "Write a haiku about code" +# -> Router spawns Writing Agent + +pnpm openclaw agent --message "Review this function for bugs" +# -> Router spawns Code Agent + +# Chat with a specific agent directly (bypasses router) +pnpm openclaw agent --message "Hello" --agent researcher +pnpm openclaw agent --message "Hello" --agent writer +pnpm openclaw agent --message "Hello" --agent coder +``` + +## Slack Usage + +``` +/agent researcher What is quantum computing? +/agent writer Draft an email about the new feature +/agent coder Review this pull request +``` + +The `/agent` slash command lets you target a specific agent in Slack. Without it, messages go to the default router agent which auto-classifies. + +## Control UI + +Open the Control UI to see the agent picker dropdown, switch between agents mid-conversation, and see which agent is responding. + +## Squad Builder (Agent Designer) + +The `designer` agent creates new agents conversationally. Instead of editing YAML by hand, chat with the Architect to design and deploy agents on the fly: + +```bash +pnpm openclaw agent --message "Create a new agent called translator that translates text between languages" --agent designer +``` + +The designer uses `config.patch` to safely append new agents to your config and `agents_files_set` to create workspace files (SOUL.md, IDENTITY.md). After creation, verify with: + +```bash +pnpm openclaw gateway call agents.list +``` + +## Agent Handoff + +Agents can delegate to each other using `sessions_spawn`: + +- Research Agent discovers an API -> spawns Code Agent to write the integration +- Code Agent finishes a feature -> spawns Writing Agent to document it +- Writing Agent needs technical details -> spawns Research Agent to look them up + +The `maxSpawnDepth: 2` setting enables orchestrator patterns where an agent can spawn sub-agents that spawn their own sub-agents. diff --git a/examples/multi-agent-poc/config.yaml b/examples/multi-agent-poc/config.yaml new file mode 100644 index 000000000000..a6b6b271fc7e --- /dev/null +++ b/examples/multi-agent-poc/config.yaml @@ -0,0 +1,70 @@ +# Multi-Agent POC Configuration +# Copy this into your ~/.openclaw/config.yaml (or merge with existing config) +# +# Each agent gets its own workspace, persona (SOUL.md), identity (IDENTITY.md), +# model config, and tool profile. The gateway resolves agents by ID and routes +# messages to the correct workspace. +# +# The "router" agent acts as a classifier: it receives all messages first and +# delegates to the right specialist via sessions_spawn. Users don't need to +# pick an agent manually. + +agents: + defaults: + model: + primary: claude-sonnet-4-5-20250929 + subagents: + maxSpawnDepth: 2 + + list: + - id: router + name: Router Agent + default: true + workspace: ~/.openclaw/workspaces/router + model: + primary: claude-haiku-4-5-20251001 + tools: + profile: minimal + subagents: + allowAgents: ["*"] + + - id: researcher + name: Research Agent + workspace: ~/.openclaw/workspaces/researcher + model: + primary: claude-sonnet-4-5-20250929 + tools: + profile: full + subagents: + allowAgents: ["*"] + + - id: writer + name: Writing Agent + workspace: ~/.openclaw/workspaces/writer + model: + primary: claude-sonnet-4-5-20250929 + tools: + profile: messaging + alsoAllow: + - read + + - id: coder + name: Code Agent + workspace: ~/.openclaw/workspaces/coder + model: + primary: claude-sonnet-4-5-20250929 + tools: + profile: coding + subagents: + allowAgents: ["*"] + + - id: designer + name: Agent Designer + workspace: ~/.openclaw/workspaces/designer + model: + primary: claude-sonnet-4-5-20250929 + tools: + profile: minimal + alsoAllow: + - gateway + - agents_files_set diff --git a/examples/multi-agent-poc/workspaces/coder/IDENTITY.md b/examples/multi-agent-poc/workspaces/coder/IDENTITY.md new file mode 100644 index 000000000000..9b70cbff657d --- /dev/null +++ b/examples/multi-agent-poc/workspaces/coder/IDENTITY.md @@ -0,0 +1,6 @@ +# IDENTITY.md + +- **Name:** Forge +- **Creature:** Code-forging AI +- **Vibe:** Pragmatic, precise, direct +- **Emoji:** ⚡ diff --git a/examples/multi-agent-poc/workspaces/coder/SOUL.md b/examples/multi-agent-poc/workspaces/coder/SOUL.md new file mode 100644 index 000000000000..875c2863419c --- /dev/null +++ b/examples/multi-agent-poc/workspaces/coder/SOUL.md @@ -0,0 +1,35 @@ +# SOUL.md - Code Agent + +You are a coding specialist. Your purpose is to write, review, debug, and explain code. + +## Core Behavior + +- Read existing code before modifying it. Understand context first. +- Write clean, tested, minimal code. Don't over-engineer. +- Explain your reasoning when making architectural choices. +- Run tests after making changes. Don't assume things work without verification. + +## What You Do + +- Write new code and features +- Debug and fix issues +- Review code for bugs, performance, and style +- Explain code and architectural decisions +- Set up development environments and tooling + +## What You Don't Do + +- Write long-form prose or documentation (hand off to the Writing Agent) +- Deep research on non-technical topics (hand off to the Research Agent) +- Deploy to production without explicit user approval + +## Handoff + +If a task is better suited for another agent, use `sessions_spawn` to delegate: + +- Research tasks -> `researcher` +- Writing/docs tasks -> `writer` + +## Vibe + +Pragmatic, precise, and opinionated about code quality. You're the person who ships working code. diff --git a/examples/multi-agent-poc/workspaces/designer/IDENTITY.md b/examples/multi-agent-poc/workspaces/designer/IDENTITY.md new file mode 100644 index 000000000000..139993888c2e --- /dev/null +++ b/examples/multi-agent-poc/workspaces/designer/IDENTITY.md @@ -0,0 +1,6 @@ +# IDENTITY.md + +- **Name:** Architect +- **Creature:** Agent designer +- **Vibe:** Creative, methodical +- **Emoji:** 🏗️ diff --git a/examples/multi-agent-poc/workspaces/designer/SOUL.md b/examples/multi-agent-poc/workspaces/designer/SOUL.md new file mode 100644 index 000000000000..f6b2cfd8a2f3 --- /dev/null +++ b/examples/multi-agent-poc/workspaces/designer/SOUL.md @@ -0,0 +1,59 @@ +# SOUL.md - Agent Architect + +You are the Agent Architect. You help users design and create new OpenClaw agents through conversation. + +## Your Process + +1. **Understand the need.** Ask the user what the new agent should do. Clarify its purpose, specialization, and how it fits alongside existing agents. +2. **Gather details.** Ask about: + - Agent name and a short ID (lowercase, no spaces) + - Emoji that represents the agent + - Model preference (default: claude-sonnet-4-5-20250929) + - Tool profile: `minimal`, `messaging`, `coding`, or `full` + - Whether it should be able to spawn subagents +3. **Create the agent.** Use the `gateway` tool with `config.patch` to append the new agent to `agents.list`. The patch YAML should use `mergeObjectArraysById: true` semantics (the gateway handles this automatically for config.patch). +4. **Set up workspace files.** Use `agents_files_set` to create SOUL.md and IDENTITY.md in the new agent's workspace. +5. **Confirm.** Tell the user the agent is ready and how to interact with it. + +## Example Conversation + +User: "I need an agent that translates text between languages" + +You: "Great idea! Let me help you set that up. A few questions: + +- What should we call it? I'd suggest 'Translator' with ID `translator` +- Emoji suggestion: 🌐 +- Should it have web search access for looking up idioms? (full profile) Or just messaging? +- Any specific languages it should specialize in?" + +User: "Translator is perfect, use the globe emoji, messaging profile is fine" + +You: "Creating your Translator agent now..." +_Use gateway tool with config.patch to add the agent_ +_Use agents_files_set to create SOUL.md and IDENTITY.md_ +"Done! Your Translator agent is ready. You can talk to it with: +`/agent translator Translate 'hello world' to Japanese`" + +## Config Patch Format + +When creating an agent, your config.patch `raw` YAML should look like: + +```yaml +agents: + list: + - id: + name: + workspace: ~/.openclaw/workspaces/ + model: + primary: + tools: + profile: +``` + +## Rules + +- Always confirm the details before creating the agent +- Use sensible defaults (Sonnet for model, messaging for profile) +- Create meaningful SOUL.md content that gives the agent a clear purpose +- Keep IDENTITY.md short: name, emoji, vibe +- Suggest a router classification rule the user can add to the router's SOUL.md diff --git a/examples/multi-agent-poc/workspaces/researcher/IDENTITY.md b/examples/multi-agent-poc/workspaces/researcher/IDENTITY.md new file mode 100644 index 000000000000..7439f243cdac --- /dev/null +++ b/examples/multi-agent-poc/workspaces/researcher/IDENTITY.md @@ -0,0 +1,6 @@ +# IDENTITY.md + +- **Name:** Scout +- **Creature:** Research librarian AI +- **Vibe:** Thorough, methodical, curious +- **Emoji:** 🔍 diff --git a/examples/multi-agent-poc/workspaces/researcher/SOUL.md b/examples/multi-agent-poc/workspaces/researcher/SOUL.md new file mode 100644 index 000000000000..1596bd148712 --- /dev/null +++ b/examples/multi-agent-poc/workspaces/researcher/SOUL.md @@ -0,0 +1,34 @@ +# SOUL.md - Research Agent + +You are a research specialist. Your purpose is to find, synthesize, and present information clearly. + +## Core Behavior + +- Search thoroughly before answering. Use web search, read files, and cross-reference sources. +- Present findings with structure: summaries first, details on request. +- Cite sources when possible. Distinguish between facts and your interpretation. +- When a question is beyond your expertise, say so and suggest who might help (e.g., the Code Agent for technical deep-dives). + +## What You Do + +- Answer factual questions with sourced information +- Summarize documents, articles, and codebases +- Compare options with structured pros/cons +- Research technical topics, APIs, and documentation + +## What You Don't Do + +- Write long-form content (hand off to the Writing Agent) +- Write or modify code (hand off to the Code Agent) +- Make decisions for the user -- present options and let them choose + +## Handoff + +If a task is better suited for another agent, use `sessions_spawn` to delegate: + +- Writing tasks -> `writer` +- Code tasks -> `coder` + +## Vibe + +Thorough but concise. You're the person who actually reads the docs. diff --git a/examples/multi-agent-poc/workspaces/router/IDENTITY.md b/examples/multi-agent-poc/workspaces/router/IDENTITY.md new file mode 100644 index 000000000000..453fca6a1e03 --- /dev/null +++ b/examples/multi-agent-poc/workspaces/router/IDENTITY.md @@ -0,0 +1,6 @@ +# IDENTITY.md + +- **Name:** Switchboard +- **Creature:** Intent classifier +- **Vibe:** Fast, decisive, invisible +- **Emoji:** 🔀 diff --git a/examples/multi-agent-poc/workspaces/router/SOUL.md b/examples/multi-agent-poc/workspaces/router/SOUL.md new file mode 100644 index 000000000000..98a6db5ff077 --- /dev/null +++ b/examples/multi-agent-poc/workspaces/router/SOUL.md @@ -0,0 +1,35 @@ +# SOUL.md - Router Agent + +You are an intent classifier and task router. Your job is to understand what the user needs and delegate to the right specialist agent. + +## Available Agents + +| Agent ID | Specialization | +| ------------ | -------------------------------------------------------- | +| `researcher` | Research, fact-finding, summarization, comparing options | +| `writer` | Writing, editing, communications, documentation | +| `coder` | Code, debugging, reviews, technical implementation | + +## How You Work + +1. Read the user's message +2. Classify the intent +3. Spawn the right agent using `sessions_spawn` with the user's message as the task +4. If the intent is ambiguous or spans multiple specializations, pick the primary one and note in the task that the agent can hand off to others if needed + +## Classification Rules + +- **Research**: questions, "what is", "how does", "compare", "find", "look up", fact-checking +- **Writing**: "write", "draft", "edit", "summarize for", "email", "document", "README" +- **Code**: "code", "implement", "debug", "fix", "review", "build", "deploy", "test", programming languages +- **Ambiguous**: if the message could go either way, prefer the agent whose specialization is most critical to the task. "Write a Python script" -> coder. "Write a blog post about Python" -> writer. + +## What You Don't Do + +- Don't answer questions directly -- always delegate +- Don't hold conversations -- route and get out of the way +- Don't add commentary beyond what's needed to route the task + +## Vibe + +Fast, decisive, invisible. You're the switchboard operator, not the caller. diff --git a/examples/multi-agent-poc/workspaces/writer/IDENTITY.md b/examples/multi-agent-poc/workspaces/writer/IDENTITY.md new file mode 100644 index 000000000000..d917fc53ca8b --- /dev/null +++ b/examples/multi-agent-poc/workspaces/writer/IDENTITY.md @@ -0,0 +1,6 @@ +# IDENTITY.md + +- **Name:** Quill +- **Creature:** Wordsmith AI +- **Vibe:** Clear, articulate, attentive +- **Emoji:** ✍️ diff --git a/examples/multi-agent-poc/workspaces/writer/SOUL.md b/examples/multi-agent-poc/workspaces/writer/SOUL.md new file mode 100644 index 000000000000..30d895bbfe4b --- /dev/null +++ b/examples/multi-agent-poc/workspaces/writer/SOUL.md @@ -0,0 +1,35 @@ +# SOUL.md - Writing Agent + +You are a writing specialist. Your purpose is to create clear, well-structured written content. + +## Core Behavior + +- Write clearly and concisely. Match the user's tone and audience. +- Structure content with headings, lists, and paragraphs as appropriate. +- Edit and refine iteratively when asked. First drafts are starting points, not final products. +- Ask clarifying questions about audience, tone, and purpose before starting long-form pieces. + +## What You Do + +- Draft emails, messages, and communications +- Write documentation, guides, and READMEs +- Edit and improve existing text +- Summarize and rewrite content for different audiences +- Create structured outlines and plans + +## What You Don't Do + +- Deep research (hand off to the Research Agent) +- Write or modify code (hand off to the Code Agent) +- Make promises or commitments on behalf of the user + +## Handoff + +If a task is better suited for another agent, use `sessions_spawn` to delegate: + +- Research tasks -> `researcher` +- Code tasks -> `coder` + +## Vibe + +Clear, adaptable, and attentive to detail. You're the person who makes things read well. diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index eed12b72d418..10d55dcd991b 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -18,6 +18,7 @@ import { createSessionsListTool } from "./tools/sessions-list-tool.js"; import { createSessionsSendTool } from "./tools/sessions-send-tool.js"; import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; import { createSubagentsTool } from "./tools/subagents-tool.js"; +import { createTasksTool } from "./tools/tasks-tool.js"; import { createTtsTool } from "./tools/tts-tool.js"; import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js"; import { resolveWorkspaceRoot } from "./workspace-dir.js"; @@ -155,6 +156,10 @@ export function createOpenClawTools(options?: { agentSessionKey: options?.agentSessionKey, config: options?.config, }), + createTasksTool({ + agentSessionKey: options?.agentSessionKey, + workspaceDir: options?.workspaceDir, + }), ...(webSearchTool ? [webSearchTool] : []), ...(webFetchTool ? [webFetchTool] : []), ...(imageTool ? [imageTool] : []), diff --git a/src/agents/tasks-store.ts b/src/agents/tasks-store.ts new file mode 100644 index 000000000000..079a11f377c9 --- /dev/null +++ b/src/agents/tasks-store.ts @@ -0,0 +1,120 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +export type TaskStatus = "open" | "in_progress" | "done"; + +export type Task = { + id: string; + title: string; + status: TaskStatus; + agentId: string; + sessionKey?: string; + createdAt: number; + updatedAt: number; + metadata?: Record; +}; + +type TasksFile = { + tasks: Task[]; +}; + +function resolveTasksPath(workspaceDir: string): string { + return path.join(workspaceDir, "tasks.json"); +} + +async function readTasksFile(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, "utf-8"); + const parsed = JSON.parse(raw) as TasksFile; + if (!Array.isArray(parsed.tasks)) { + return { tasks: [] }; + } + return parsed; + } catch { + return { tasks: [] }; + } +} + +async function writeTasksFile(filePath: string, data: TasksFile): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8"); +} + +export async function listTasks( + workspaceDir: string, + filter?: { status?: TaskStatus; agentId?: string }, +): Promise { + const filePath = resolveTasksPath(workspaceDir); + const data = await readTasksFile(filePath); + let tasks = data.tasks; + if (filter?.status) { + tasks = tasks.filter((t) => t.status === filter.status); + } + if (filter?.agentId) { + tasks = tasks.filter((t) => t.agentId === filter.agentId); + } + return tasks; +} + +export async function getTask(workspaceDir: string, id: string): Promise { + const filePath = resolveTasksPath(workspaceDir); + const data = await readTasksFile(filePath); + return data.tasks.find((t) => t.id === id) ?? null; +} + +export async function createTask( + workspaceDir: string, + params: { + title: string; + agentId: string; + sessionKey?: string; + metadata?: Record; + }, +): Promise { + const filePath = resolveTasksPath(workspaceDir); + const data = await readTasksFile(filePath); + const now = Date.now(); + const task: Task = { + id: crypto.randomUUID(), + title: params.title, + status: "open", + agentId: params.agentId, + sessionKey: params.sessionKey, + createdAt: now, + updatedAt: now, + metadata: params.metadata, + }; + data.tasks.push(task); + await writeTasksFile(filePath, data); + return task; +} + +export async function updateTask( + workspaceDir: string, + id: string, + patch: { + title?: string; + status?: TaskStatus; + metadata?: Record; + }, +): Promise { + const filePath = resolveTasksPath(workspaceDir); + const data = await readTasksFile(filePath); + const task = data.tasks.find((t) => t.id === id); + if (!task) { + return null; + } + if (patch.title !== undefined) { + task.title = patch.title; + } + if (patch.status !== undefined) { + task.status = patch.status; + } + if (patch.metadata !== undefined) { + task.metadata = { ...task.metadata, ...patch.metadata }; + } + task.updatedAt = Date.now(); + await writeTasksFile(filePath, data); + return task; +} diff --git a/src/agents/tools/tasks-tool.ts b/src/agents/tools/tasks-tool.ts new file mode 100644 index 000000000000..968feeb1c2d6 --- /dev/null +++ b/src/agents/tools/tasks-tool.ts @@ -0,0 +1,104 @@ +import { Type } from "@sinclair/typebox"; +import { stringEnum } from "../schema/typebox.js"; +import { createTask, getTask, listTasks, updateTask, type TaskStatus } from "../tasks-store.js"; +import { resolveWorkspaceRoot } from "../workspace-dir.js"; +import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; + +const TASK_ACTIONS = ["create", "list", "update", "complete", "get"] as const; +const TASK_STATUSES = ["open", "in_progress", "done"] as const; + +const TasksToolSchema = Type.Object({ + action: stringEnum(TASK_ACTIONS), + // create + title: Type.Optional(Type.String()), + // create, list, get, update, complete + agentId: Type.Optional(Type.String()), + sessionKey: Type.Optional(Type.String()), + // list + status: Type.Optional(stringEnum(TASK_STATUSES)), + // update, complete, get + id: Type.Optional(Type.String()), + // update + metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown())), +}); + +export function createTasksTool(opts?: { + agentSessionKey?: string; + workspaceDir?: string; +}): AnyAgentTool { + return { + label: "Tasks", + name: "tasks", + description: + "Create, list, update, and complete persistent tasks that survive across sessions. Tasks are stored in the workspace and can be shared between agents.", + parameters: TasksToolSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const action = readStringParam(params, "action", { required: true }); + const workspaceDir = resolveWorkspaceRoot(opts?.workspaceDir); + + if (action === "create") { + const title = readStringParam(params, "title", { required: true }); + const agentId = readStringParam(params, "agentId") ?? "unknown"; + const sessionKey = readStringParam(params, "sessionKey") ?? opts?.agentSessionKey; + const metadata = + params.metadata && typeof params.metadata === "object" + ? (params.metadata as Record) + : undefined; + const task = await createTask(workspaceDir, { + title, + agentId, + sessionKey, + metadata, + }); + return jsonResult({ ok: true, task }); + } + + if (action === "list") { + const status = readStringParam(params, "status") as TaskStatus | undefined; + const agentId = readStringParam(params, "agentId"); + const tasks = await listTasks(workspaceDir, { status, agentId }); + return jsonResult({ ok: true, tasks, count: tasks.length }); + } + + if (action === "get") { + const id = readStringParam(params, "id", { required: true }); + const task = await getTask(workspaceDir, id); + if (!task) { + return jsonResult({ ok: false, error: `Task not found: ${id}` }); + } + return jsonResult({ ok: true, task }); + } + + if (action === "update") { + const id = readStringParam(params, "id", { required: true }); + const title = readStringParam(params, "title"); + const status = readStringParam(params, "status") as TaskStatus | undefined; + const metadata = + params.metadata && typeof params.metadata === "object" + ? (params.metadata as Record) + : undefined; + const task = await updateTask(workspaceDir, id, { + title, + status, + metadata, + }); + if (!task) { + return jsonResult({ ok: false, error: `Task not found: ${id}` }); + } + return jsonResult({ ok: true, task }); + } + + if (action === "complete") { + const id = readStringParam(params, "id", { required: true }); + const task = await updateTask(workspaceDir, id, { status: "done" }); + if (!task) { + return jsonResult({ ok: false, error: `Task not found: ${id}` }); + } + return jsonResult({ ok: true, task }); + } + + throw new Error(`Unknown tasks action: ${action}`); + }, + }; +} diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index a799d1358ff8..ad99501b7ef6 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -3,7 +3,9 @@ import type { CommandCategory, CommandScope, } from "./commands-registry.types.js"; +import { listAgentIds } from "../agents/agent-scope.js"; import { listChannelDocks } from "../channels/dock.js"; +import { loadConfig } from "../config/config.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; import { COMMAND_ARG_FORMATTERS } from "./commands-args.js"; import { listThinkingLevels } from "./thinking.js"; @@ -607,6 +609,32 @@ function buildChatCommands(): ChatCommandDefinition[] { }, ], }), + defineChatCommand({ + key: "agent", + nativeName: "agent", + description: "Send a message to a specific agent.", + textAlias: "/agent", + category: "management", + args: [ + { + name: "agentId", + description: "Agent id", + type: "string", + required: true, + choices: ({ cfg }) => { + const config = cfg ?? loadConfig(); + return listAgentIds(config).map((id) => ({ value: id, label: id })); + }, + }, + { + name: "message", + description: "Message to send", + type: "string", + captureRemaining: true, + }, + ], + argsMenu: { arg: "agentId", title: "Choose an agent:" }, + }), ...listChannelDocks() .filter((dock) => dock.capabilities.nativeCommands) .map((dock) => defineDockCommand(dock)), diff --git a/src/gateway/protocol/schema/CLAUDE.md b/src/gateway/protocol/schema/CLAUDE.md new file mode 100644 index 000000000000..42379414dab4 --- /dev/null +++ b/src/gateway/protocol/schema/CLAUDE.md @@ -0,0 +1,7 @@ + +# Recent Activity + + + +_No recent activity_ + diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index e6086301c7b7..f271cf3454d9 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -19,6 +19,7 @@ import { sessionsHandlers } from "./server-methods/sessions.js"; import { skillsHandlers } from "./server-methods/skills.js"; import { systemHandlers } from "./server-methods/system.js"; import { talkHandlers } from "./server-methods/talk.js"; +import { tasksHandlers } from "./server-methods/tasks.js"; import { ttsHandlers } from "./server-methods/tts.js"; import { updateHandlers } from "./server-methods/update.js"; import { usageHandlers } from "./server-methods/usage.js"; @@ -78,6 +79,8 @@ const READ_METHODS = new Set([ "chat.history", "config.get", "talk.config", + "tasks.list", + "tasks.get", ]); const WRITE_METHODS = new Set([ "send", @@ -94,6 +97,8 @@ const WRITE_METHODS = new Set([ "chat.send", "chat.abort", "browser.request", + "tasks.create", + "tasks.update", ]); function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) { @@ -194,6 +199,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ...agentHandlers, ...agentsHandlers, ...browserHandlers, + ...tasksHandlers, }; export async function handleGatewayRequest( diff --git a/src/gateway/server-methods/tasks.ts b/src/gateway/server-methods/tasks.ts new file mode 100644 index 000000000000..ca272be5db05 --- /dev/null +++ b/src/gateway/server-methods/tasks.ts @@ -0,0 +1,134 @@ +import type { GatewayRequestHandlers } from "./types.js"; +import { + createTask, + getTask, + listTasks, + updateTask, + type TaskStatus, +} from "../../agents/tasks-store.js"; +import { resolveWorkspaceRoot } from "../../agents/workspace-dir.js"; +import { ErrorCodes, errorShape } from "../protocol/index.js"; + +function resolveWorkspace(params: Record) { + const workspace = + typeof params.workspace === "string" && params.workspace.trim() + ? params.workspace.trim() + : undefined; + return resolveWorkspaceRoot(workspace); +} + +export const tasksHandlers: GatewayRequestHandlers = { + "tasks.list": async ({ params, respond }) => { + try { + const workspaceDir = resolveWorkspace(params); + const status = + typeof params.status === "string" && params.status.trim() + ? (params.status.trim() as TaskStatus) + : undefined; + const agentId = + typeof params.agentId === "string" && params.agentId.trim() + ? params.agentId.trim() + : undefined; + const tasks = await listTasks(workspaceDir, { status, agentId }); + respond(true, { tasks, count: tasks.length }); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, `tasks.list failed: ${String(err)}`), + ); + } + }, + + "tasks.get": async ({ params, respond }) => { + const id = typeof params.id === "string" ? params.id.trim() : ""; + if (!id) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "id required")); + return; + } + try { + const workspaceDir = resolveWorkspace(params); + const task = await getTask(workspaceDir, id); + if (!task) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `task not found: ${id}`)); + return; + } + respond(true, { task }); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, `tasks.get failed: ${String(err)}`), + ); + } + }, + + "tasks.create": async ({ params, respond }) => { + const title = typeof params.title === "string" ? params.title.trim() : ""; + if (!title) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "title required")); + return; + } + const agentId = + typeof params.agentId === "string" && params.agentId.trim() + ? params.agentId.trim() + : "unknown"; + const sessionKey = + typeof params.sessionKey === "string" && params.sessionKey.trim() + ? params.sessionKey.trim() + : undefined; + const metadata = + params.metadata && typeof params.metadata === "object" && !Array.isArray(params.metadata) + ? (params.metadata as Record) + : undefined; + try { + const workspaceDir = resolveWorkspace(params); + const task = await createTask(workspaceDir, { + title, + agentId, + sessionKey, + metadata, + }); + respond(true, { task }); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, `tasks.create failed: ${String(err)}`), + ); + } + }, + + "tasks.update": async ({ params, respond }) => { + const id = typeof params.id === "string" ? params.id.trim() : ""; + if (!id) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "id required")); + return; + } + const title = + typeof params.title === "string" && params.title.trim() ? params.title.trim() : undefined; + const status = + typeof params.status === "string" && params.status.trim() + ? (params.status.trim() as TaskStatus) + : undefined; + const metadata = + params.metadata && typeof params.metadata === "object" && !Array.isArray(params.metadata) + ? (params.metadata as Record) + : undefined; + try { + const workspaceDir = resolveWorkspace(params); + const task = await updateTask(workspaceDir, id, { title, status, metadata }); + if (!task) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `task not found: ${id}`)); + return; + } + respond(true, { task }); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, `tasks.update failed: ${String(err)}`), + ); + } + }, +}; diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index 3ee2b30833db..21f0b382fa76 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -26,6 +26,7 @@ import { upsertChannelPairingRequest, } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js"; import { normalizeAllowList, @@ -387,6 +388,23 @@ export function registerSlackMonitorSlashCommands(params: { }, }); + // /agent — override the route to target a specific agent + let agentPrompt = prompt; + if (commandDefinition?.key === "agent" && commandArgs?.values?.agentId) { + const targetAgentId = normalizeAgentId(String(commandArgs.values.agentId)); + if (targetAgentId) { + route.agentId = targetAgentId; + route.matchedBy = "default"; + // Use just the message portion as the prompt, not "/agent " + const message = commandArgs.values.message + ? String(commandArgs.values.message).trim() + : ""; + if (message) { + agentPrompt = message; + } + } + } + const untrustedChannelMetadata = isRoomish ? buildUntrustedChannelMetadata({ source: "slack", @@ -401,8 +419,8 @@ export function registerSlackMonitorSlashCommands(params: { systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; const ctxPayload = finalizeInboundContext({ - Body: prompt, - BodyForAgent: prompt, + Body: agentPrompt, + BodyForAgent: agentPrompt, RawBody: prompt, CommandBody: prompt, CommandArgs: commandArgs, diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 821ecbd3abc9..ba37994fde19 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -331,6 +331,19 @@ flex-wrap: wrap; } +.chat-controls__agent { + min-width: 120px; + max-width: 240px; +} + +.chat-controls__agent select { + padding: 6px 10px; + font-size: 13px; + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; +} + .chat-controls__session { min-width: 140px; max-width: 420px; diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index a7e0b9aa2b3f..865489309bc0 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -1,10 +1,11 @@ -import { html } from "lit"; +import { html, nothing } from "lit"; import { repeat } from "lit/directives/repeat.js"; import type { AppViewState } from "./app-view-state.ts"; import type { ThemeTransitionContext } from "./theme-transition.ts"; import type { ThemeMode } from "./theme.ts"; -import type { SessionsListResult } from "./types.ts"; -import { refreshChat } from "./app-chat.ts"; +import type { GatewayAgentRow, SessionsListResult } from "./types.ts"; +import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js"; +import { refreshChat, refreshChatAvatar } from "./app-chat.ts"; import { syncUrlWithSessionKey } from "./app-settings.ts"; import { OpenClawApp } from "./app.ts"; import { ChatState, loadChatHistory } from "./controllers/chat.ts"; @@ -81,6 +82,82 @@ export function renderTab(state: AppViewState, tab: Tab) { `; } +function resolveCurrentAgentId(sessionKey: string, agentsList: AppViewState["agentsList"]): string { + const parsed = parseAgentSessionKey(sessionKey); + if (parsed?.agentId) { + return parsed.agentId; + } + return agentsList?.defaultId ?? "main"; +} + +function buildAgentSessionKey(agentId: string, mainKey?: string): string { + const key = mainKey || "main"; + return `agent:${agentId}:${key}`; +} + +function switchToAgent(state: AppViewState, agentId: string) { + const nextSessionKey = buildAgentSessionKey(agentId); + state.sessionKey = nextSessionKey; + state.chatMessage = ""; + state.chatStream = null; + (state as unknown as OpenClawApp).chatStreamStartedAt = null; + state.chatRunId = null; + (state as unknown as OpenClawApp).resetToolStream(); + (state as unknown as OpenClawApp).resetChatScroll(); + state.applySettings({ + ...state.settings, + sessionKey: nextSessionKey, + lastActiveSessionKey: nextSessionKey, + }); + void state.loadAssistantIdentity(); + syncUrlWithSessionKey( + state as unknown as Parameters[0], + nextSessionKey, + true, + ); + void loadChatHistory(state as unknown as ChatState); + void refreshChatAvatar(state as unknown as Parameters[0]); +} + +function renderAgentLabel(agent: GatewayAgentRow): string { + const emoji = agent.identity?.emoji; + const name = agent.identity?.name || agent.name || agent.id; + return emoji ? `${emoji} ${name}` : name; +} + +function renderAgentPicker(state: AppViewState) { + const agents = state.agentsList?.agents; + if (!agents || agents.length <= 1) { + return nothing; + } + const currentAgentId = resolveCurrentAgentId(state.sessionKey, state.agentsList); + return html` + + | + `; +} + export function renderChatControls(state: AppViewState) { const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult); const sessionOptions = resolveSessionOptions( @@ -128,6 +205,7 @@ export function renderChatControls(state: AppViewState) { `; return html`
+ ${renderAgentPicker(state)} + + `; +} diff --git a/ui/src/ui/views/agents.ts b/ui/src/ui/views/agents.ts index f8cf5cb5f572..afa902da1cf7 100644 --- a/ui/src/ui/views/agents.ts +++ b/ui/src/ui/views/agents.ts @@ -1,4 +1,5 @@ import { html, nothing } from "lit"; +import type { ActivityEntry } from "../app-tool-stream.ts"; import type { AgentIdentityResult, AgentsFilesListResult, @@ -8,6 +9,8 @@ import type { CronStatus, SkillStatusReport, } from "../types.ts"; +import { renderActivityFeed } from "./agents-panels-activity.ts"; +import { renderSoulEditor } from "./agents-panels-soul.ts"; import { renderAgentFiles, renderAgentChannels, @@ -28,7 +31,15 @@ import { resolveModelPrimary, } from "./agents-utils.ts"; -export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron"; +export type AgentsPanel = + | "overview" + | "files" + | "tools" + | "skills" + | "channels" + | "cron" + | "activity" + | "soul"; export type AgentsProps = { loading: boolean; @@ -63,6 +74,7 @@ export type AgentsProps = { agentSkillsError: string | null; agentSkillsAgentId: string | null; skillsFilter: string; + activityFeed: ActivityEntry[]; onRefresh: () => void; onSelectAgent: (agentId: string) => void; onSelectPanel: (panel: AgentsPanel) => void; @@ -278,6 +290,25 @@ export function renderAgents(props: AgentsProps) { }) : nothing } + ${ + props.activePanel === "activity" + ? renderActivityFeed(props.activityFeed) + : nothing + } + ${ + props.activePanel === "soul" + ? renderSoulEditor({ + agentId: selectedAgent.id, + agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null, + agentFileContents: props.agentFileContents, + agentFileDrafts: props.agentFileDrafts, + agentFileSaving: props.agentFileSaving, + onFileDraftChange: props.onFileDraftChange, + onFileReset: props.onFileReset, + onFileSave: props.onFileSave, + }) + : nothing + } ` } @@ -314,11 +345,13 @@ function renderAgentHeader( function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) => void) { const tabs: Array<{ id: AgentsPanel; label: string }> = [ { id: "overview", label: "Overview" }, + { id: "soul", label: "Soul" }, { id: "files", label: "Files" }, { id: "tools", label: "Tools" }, { id: "skills", label: "Skills" }, { id: "channels", label: "Channels" }, { id: "cron", label: "Cron Jobs" }, + { id: "activity", label: "Activity" }, ]; return html`
From df153daedd74b4b4f2b14650acd7985b690e17a4 Mon Sep 17 00:00:00 2001 From: Achuth Karakkat Date: Thu, 19 Feb 2026 07:07:38 +0530 Subject: [PATCH 2/6] fix(ui): await file list load before fetching SOUL.md content in Soul tab --- ui/src/ui/app-render.ts | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 7c15e1090ab0..96bfba6a859c 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -392,7 +392,16 @@ export function renderApp(state: AppViewState) { state.agentSkillsError = null; state.agentSkillsAgentId = null; void loadAgentIdentity(state, agentId); - if (state.agentsPanel === "files" || state.agentsPanel === "soul") { + if (state.agentsPanel === "soul") { + const loadSoul = async () => { + await loadAgentFiles(state, agentId); + state.agentFileActive = "SOUL.md"; + if (!state.agentFileContents["SOUL.md"]) { + void loadAgentFileContent(state, agentId, "SOUL.md"); + } + }; + void loadSoul(); + } else if (state.agentsPanel === "files") { void loadAgentFiles(state, agentId); } if (state.agentsPanel === "skills") { @@ -402,19 +411,27 @@ export function renderApp(state: AppViewState) { onSelectPanel: (panel) => { state.agentsPanel = panel; if ((panel === "files" || panel === "soul") && resolvedAgentId) { - if (state.agentFilesList?.agentId !== resolvedAgentId) { + const needsFileList = state.agentFilesList?.agentId !== resolvedAgentId; + if (needsFileList) { state.agentFilesList = null; state.agentFilesError = null; state.agentFileActive = null; state.agentFileContents = {}; state.agentFileDrafts = {}; - void loadAgentFiles(state, resolvedAgentId); } if (panel === "soul") { state.agentFileActive = "SOUL.md"; - if (resolvedAgentId && !state.agentFileContents["SOUL.md"]) { - void loadAgentFileContent(state, resolvedAgentId, "SOUL.md"); - } + const loadSoul = async () => { + if (needsFileList) { + await loadAgentFiles(state, resolvedAgentId); + } + if (!state.agentFileContents["SOUL.md"]) { + void loadAgentFileContent(state, resolvedAgentId, "SOUL.md"); + } + }; + void loadSoul(); + } else if (needsFileList) { + void loadAgentFiles(state, resolvedAgentId); } } if (panel === "skills") { From 37583ed7b1231d97cf9b872828abd04ba738747e Mon Sep 17 00:00:00 2001 From: Achuth Karakkat Date: Thu, 19 Feb 2026 07:52:53 +0530 Subject: [PATCH 3/6] feat: add first-class teams config with auto-wired spawn permissions and REST API Introduces a `teams` config key that declares teams (lead + members), auto-wires spawn permissions for team leads, injects team context into lead agent prompts, and exposes teams via HTTP REST API + WebSocket RPC for external tools. - Config: TeamConfig type, Zod validation (lead/members must exist in agents.list) - Resolution: resolveTeam, listTeamsResolved, buildTeamContextPrompt, isTeamLead - Auto-wire: team leads automatically get spawn permissions for their members - Gateway RPC: teams.list, teams.run, teams.status handlers - HTTP REST: GET /v1/teams, POST /v1/teams/:id/run, GET /v1/teams/:id/status - Control UI: Teams tab with overview, chat, and status panels - Tests: 31 tests covering resolve, auto-wire, and config validation --- src/agents/tools/sessions-spawn-tool.ts | 9 +- src/config/config.teams-validation.test.ts | 100 +++++++++ src/config/types.openclaw.ts | 2 + src/config/types.teams.ts | 9 + src/config/types.ts | 1 + src/config/zod-schema.teams.ts | 13 ++ src/config/zod-schema.ts | 28 +++ src/gateway/protocol/index.ts | 15 ++ src/gateway/protocol/schema.ts | 1 + src/gateway/protocol/schema/teams.ts | 29 +++ src/gateway/server-http.ts | 10 + src/gateway/server-methods-list.ts | 3 + src/gateway/server-methods.ts | 5 + src/gateway/server-methods/teams.ts | 181 ++++++++++++++++ src/gateway/teams-http.ts | 170 +++++++++++++++ src/teams/auto-wire.test.ts | 91 ++++++++ src/teams/auto-wire.ts | 41 ++++ src/teams/resolve.test.ts | 155 ++++++++++++++ src/teams/resolve.ts | 106 +++++++++ ui/src/ui/app-render.ts | 29 +++ ui/src/ui/app-settings.ts | 4 + ui/src/ui/app-view-state.ts | 10 + ui/src/ui/app.ts | 11 + ui/src/ui/controllers/teams.ts | 98 +++++++++ ui/src/ui/navigation.ts | 10 +- ui/src/ui/types.ts | 19 ++ ui/src/ui/views/teams.ts | 237 +++++++++++++++++++++ 27 files changed, 1385 insertions(+), 2 deletions(-) create mode 100644 src/config/config.teams-validation.test.ts create mode 100644 src/config/types.teams.ts create mode 100644 src/config/zod-schema.teams.ts create mode 100644 src/gateway/protocol/schema/teams.ts create mode 100644 src/gateway/server-methods/teams.ts create mode 100644 src/gateway/teams-http.ts create mode 100644 src/teams/auto-wire.test.ts create mode 100644 src/teams/auto-wire.ts create mode 100644 src/teams/resolve.test.ts create mode 100644 src/teams/resolve.ts create mode 100644 ui/src/ui/controllers/teams.ts create mode 100644 ui/src/ui/views/teams.ts diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 11486c025e3a..f57ee4e2db21 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -6,6 +6,7 @@ import { formatThinkingLevels, normalizeThinkLevel } from "../../auto-reply/thin import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; +import { resolveEffectiveAllowAgents } from "../../teams/auto-wire.js"; import { normalizeDeliveryContext } from "../../utils/delivery-context.js"; import { resolveAgentConfig } from "../agent-scope.js"; import { AGENT_LANE_SUBAGENT } from "../lanes.js"; @@ -145,7 +146,13 @@ export function createSessionsSpawnTool(opts?: { ? normalizeAgentId(requestedAgentId) : requesterAgentId; if (targetAgentId !== requesterAgentId) { - const allowAgents = resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ?? []; + const configuredAllowAgents = resolveAgentConfig(cfg, requesterAgentId)?.subagents + ?.allowAgents; + const allowAgents = resolveEffectiveAllowAgents( + cfg, + requesterAgentId, + configuredAllowAgents, + ); const allowAny = allowAgents.some((value) => value.trim() === "*"); const normalizedTargetId = targetAgentId.toLowerCase(); const allowSet = new Set( diff --git a/src/config/config.teams-validation.test.ts b/src/config/config.teams-validation.test.ts new file mode 100644 index 000000000000..49e7a9754486 --- /dev/null +++ b/src/config/config.teams-validation.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { OpenClawSchema } from "./zod-schema.js"; + +describe("teams config validation", () => { + it("accepts valid teams array", () => { + const result = OpenClawSchema.safeParse({ + agents: { + list: [{ id: "lead" }, { id: "member-a" }, { id: "member-b" }], + }, + teams: [ + { + id: "my-team", + name: "My Team", + lead: "lead", + members: ["member-a", "member-b"], + description: "A test team", + }, + ], + }); + expect(result.success).toBe(true); + }); + + it("accepts teams with minimal fields", () => { + const result = OpenClawSchema.safeParse({ + agents: { + list: [{ id: "lead" }, { id: "worker" }], + }, + teams: [ + { + id: "minimal", + lead: "lead", + members: ["worker"], + }, + ], + }); + expect(result.success).toBe(true); + }); + + it("accepts config with no teams", () => { + const result = OpenClawSchema.safeParse({ + agents: { list: [{ id: "solo" }] }, + }); + expect(result.success).toBe(true); + }); + + it("rejects unknown lead agent ID", () => { + const result = OpenClawSchema.safeParse({ + agents: { + list: [{ id: "worker" }], + }, + teams: [ + { + id: "bad-team", + lead: "nonexistent-lead", + members: ["worker"], + }, + ], + }); + expect(result.success).toBe(false); + if (!result.success) { + const messages = result.error.issues.map((i) => i.message); + expect(messages.some((m) => m.includes("nonexistent-lead"))).toBe(true); + } + }); + + it("rejects unknown member agent ID", () => { + const result = OpenClawSchema.safeParse({ + agents: { + list: [{ id: "lead" }], + }, + teams: [ + { + id: "bad-team", + lead: "lead", + members: ["nonexistent-member"], + }, + ], + }); + expect(result.success).toBe(false); + if (!result.success) { + const messages = result.error.issues.map((i) => i.message); + expect(messages.some((m) => m.includes("nonexistent-member"))).toBe(true); + } + }); + + it("rejects extra properties on team entries", () => { + const result = OpenClawSchema.safeParse({ + agents: { list: [{ id: "lead" }] }, + teams: [ + { + id: "bad", + lead: "lead", + members: [], + unknownField: true, + }, + ], + }); + expect(result.success).toBe(false); + }); +}); diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index a3ca92c7b9a1..eabb605c78f2 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -23,6 +23,7 @@ import type { ModelsConfig } from "./types.models.js"; import type { NodeHostConfig } from "./types.node-host.js"; import type { PluginsConfig } from "./types.plugins.js"; import type { SkillsConfig } from "./types.skills.js"; +import type { TeamConfig } from "./types.teams.js"; import type { ToolsConfig } from "./types.tools.js"; export type OpenClawConfig = { @@ -80,6 +81,7 @@ export type OpenClawConfig = { models?: ModelsConfig; nodeHost?: NodeHostConfig; agents?: AgentsConfig; + teams?: TeamConfig[]; tools?: ToolsConfig; bindings?: AgentBinding[]; broadcast?: BroadcastConfig; diff --git a/src/config/types.teams.ts b/src/config/types.teams.ts new file mode 100644 index 000000000000..7489ea5ff898 --- /dev/null +++ b/src/config/types.teams.ts @@ -0,0 +1,9 @@ +export type TeamConfig = { + id: string; + name?: string; + /** Agent ID of the orchestrator / team lead. */ + lead: string; + /** Agent IDs of team members. */ + members: string[]; + description?: string; +}; diff --git a/src/config/types.ts b/src/config/types.ts index 4260dd43931c..7bb7fc6271bb 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -27,6 +27,7 @@ export * from "./types.skills.js"; export * from "./types.slack.js"; export * from "./types.telegram.js"; export * from "./types.tts.js"; +export * from "./types.teams.js"; export * from "./types.tools.js"; export * from "./types.whatsapp.js"; export * from "./types.memory.js"; diff --git a/src/config/zod-schema.teams.ts b/src/config/zod-schema.teams.ts new file mode 100644 index 000000000000..06143e3abf1a --- /dev/null +++ b/src/config/zod-schema.teams.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +export const TeamEntrySchema = z + .object({ + id: z.string(), + name: z.string().optional(), + lead: z.string(), + members: z.array(z.string()), + description: z.string().optional(), + }) + .strict(); + +export const TeamsSchema = z.array(TeamEntrySchema).optional(); diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 7f43b4b1a089..ee8110ea1df1 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -12,6 +12,7 @@ import { SessionSchema, SessionSendPolicySchema, } from "./zod-schema.session.js"; +import { TeamsSchema } from "./zod-schema.teams.js"; const BrowserSnapshotDefaultsSchema = z .object({ @@ -276,6 +277,7 @@ export const OpenClawSchema = z models: ModelsConfigSchema, nodeHost: NodeHostSchema, agents: AgentsSchema, + teams: TeamsSchema, tools: ToolsSchema, bindings: BindingsSchema, broadcast: BroadcastSchema, @@ -640,6 +642,32 @@ export const OpenClawSchema = z } const agentIds = new Set(agents.map((agent) => agent.id)); + const teams = cfg.teams; + if (Array.isArray(teams)) { + for (let ti = 0; ti < teams.length; ti += 1) { + const team = teams[ti]; + if (team.lead && !agentIds.has(team.lead)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["teams", ti, "lead"], + message: `Unknown agent id "${team.lead}" (not in agents.list).`, + }); + } + if (Array.isArray(team.members)) { + for (let mi = 0; mi < team.members.length; mi += 1) { + const memberId = team.members[mi]; + if (!agentIds.has(memberId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["teams", ti, "members", mi], + message: `Unknown agent id "${memberId}" (not in agents.list).`, + }); + } + } + } + } + } + const broadcast = cfg.broadcast; if (!broadcast) { return; diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 98f1e0e529c4..8f8ff198a4b2 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -222,6 +222,12 @@ import { WizardStatusResultSchema, type WizardStep, WizardStepSchema, + type TeamsListParams, + TeamsListParamsSchema, + type TeamsRunParams, + TeamsRunParamsSchema, + type TeamsStatusParams, + TeamsStatusParamsSchema, } from "./schema.js"; const ajv = new (AjvPkg as unknown as new (opts?: object) => import("ajv").default)({ @@ -367,6 +373,9 @@ export const validateUpdateRunParams = ajv.compile(UpdateRunPar export const validateWebLoginStartParams = ajv.compile(WebLoginStartParamsSchema); export const validateWebLoginWaitParams = ajv.compile(WebLoginWaitParamsSchema); +export const validateTeamsListParams = ajv.compile(TeamsListParamsSchema); +export const validateTeamsRunParams = ajv.compile(TeamsRunParamsSchema); +export const validateTeamsStatusParams = ajv.compile(TeamsStatusParamsSchema); export function formatValidationErrors(errors: ErrorObject[] | null | undefined) { if (!errors?.length) { @@ -498,6 +507,9 @@ export { PROTOCOL_VERSION, ErrorCodes, errorShape, + TeamsListParamsSchema, + TeamsRunParamsSchema, + TeamsStatusParamsSchema, }; export type { @@ -600,4 +612,7 @@ export type { PollParams, UpdateRunParams, ChatInjectParams, + TeamsListParams, + TeamsRunParams, + TeamsStatusParams, }; diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 614942008840..8a8fc3764546 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -12,5 +12,6 @@ export * from "./schema/nodes.js"; export * from "./schema/protocol-schemas.js"; export * from "./schema/sessions.js"; export * from "./schema/snapshot.js"; +export * from "./schema/teams.js"; export * from "./schema/types.js"; export * from "./schema/wizard.js"; diff --git a/src/gateway/protocol/schema/teams.ts b/src/gateway/protocol/schema/teams.ts new file mode 100644 index 000000000000..5b2a523fd37f --- /dev/null +++ b/src/gateway/protocol/schema/teams.ts @@ -0,0 +1,29 @@ +import { Type, type Static } from "@sinclair/typebox"; +import { NonEmptyString } from "./primitives.js"; + +export const TeamsListParamsSchema = Type.Object({}, { additionalProperties: false }); +export type TeamsListParams = Static; + +export const TeamsRunParamsSchema = Type.Object( + { + teamId: NonEmptyString, + message: NonEmptyString, + sessionKey: Type.Optional(Type.String()), + deliver: Type.Optional(Type.Boolean()), + timeout: Type.Optional(Type.Integer({ minimum: 0 })), + channel: Type.Optional(Type.String()), + to: Type.Optional(Type.String()), + thinking: Type.Optional(Type.String()), + idempotencyKey: Type.Optional(NonEmptyString), + }, + { additionalProperties: false }, +); +export type TeamsRunParams = Static; + +export const TeamsStatusParamsSchema = Type.Object( + { + teamId: NonEmptyString, + }, + { additionalProperties: false }, +); +export type TeamsStatusParams = Static; diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 1bdd5acf2403..87e07b55adb2 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -54,6 +54,7 @@ import { getBearerToken, getHeader } from "./http-utils.js"; import { isPrivateOrLoopbackAddress, resolveGatewayClientIp } from "./net.js"; import { handleOpenAiHttpRequest } from "./openai-http.js"; import { handleOpenResponsesHttpRequest } from "./openresponses-http.js"; +import { handleTeamsHttpRequest } from "./teams-http.js"; import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js"; type SubsystemLogger = ReturnType; @@ -495,6 +496,15 @@ export function createGatewayHttpServer(opts: { ) { return; } + if ( + await handleTeamsHttpRequest(req, res, { + auth: resolvedAuth, + trustedProxies, + rateLimiter, + }) + ) { + return; + } if (await handleSlackHttpRequest(req, res)) { return; } diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index bb691f08ea39..0ee08f80120d 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -86,6 +86,9 @@ const BASE_METHODS = [ "agent.identity.get", "agent.wait", "browser.request", + "teams.list", + "teams.run", + "teams.status", // WebChat WebSocket-native chat methods "chat.history", "chat.abort", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index f271cf3454d9..70418bc1d92f 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -20,6 +20,7 @@ import { skillsHandlers } from "./server-methods/skills.js"; import { systemHandlers } from "./server-methods/system.js"; import { talkHandlers } from "./server-methods/talk.js"; import { tasksHandlers } from "./server-methods/tasks.js"; +import { teamsHandlers } from "./server-methods/teams.js"; import { ttsHandlers } from "./server-methods/tts.js"; import { updateHandlers } from "./server-methods/update.js"; import { usageHandlers } from "./server-methods/usage.js"; @@ -81,6 +82,8 @@ const READ_METHODS = new Set([ "talk.config", "tasks.list", "tasks.get", + "teams.list", + "teams.status", ]); const WRITE_METHODS = new Set([ "send", @@ -99,6 +102,7 @@ const WRITE_METHODS = new Set([ "browser.request", "tasks.create", "tasks.update", + "teams.run", ]); function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) { @@ -200,6 +204,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ...agentsHandlers, ...browserHandlers, ...tasksHandlers, + ...teamsHandlers, }; export async function handleGatewayRequest( diff --git a/src/gateway/server-methods/teams.ts b/src/gateway/server-methods/teams.ts new file mode 100644 index 000000000000..f2883db75d27 --- /dev/null +++ b/src/gateway/server-methods/teams.ts @@ -0,0 +1,181 @@ +import crypto from "node:crypto"; +import type { GatewayRequestHandlers } from "./types.js"; +import { countActiveRunsForSession } from "../../agents/subagent-registry.js"; +import { agentCommand } from "../../commands/agent.js"; +import { loadConfig } from "../../config/config.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; +import { defaultRuntime } from "../../runtime.js"; +import { resolveTeam, listTeamsResolved, buildTeamContextPrompt } from "../../teams/resolve.js"; +import { + ErrorCodes, + errorShape, + formatValidationErrors, + validateTeamsListParams, + validateTeamsRunParams, + validateTeamsStatusParams, +} from "../protocol/index.js"; +import { formatForLog } from "../ws-log.js"; + +export const teamsHandlers: GatewayRequestHandlers = { + "teams.list": ({ params, respond }) => { + if (!validateTeamsListParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid teams.list params: ${formatValidationErrors(validateTeamsListParams.errors)}`, + ), + ); + return; + } + const cfg = loadConfig(); + const teams = listTeamsResolved(cfg); + respond(true, { teams }, undefined); + }, + + "teams.run": async ({ params, respond, context }) => { + if (!validateTeamsRunParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid teams.run params: ${formatValidationErrors(validateTeamsRunParams.errors)}`, + ), + ); + return; + } + const p = params as { + teamId: string; + message: string; + sessionKey?: string; + deliver?: boolean; + timeout?: number; + channel?: string; + to?: string; + thinking?: string; + idempotencyKey?: string; + }; + const cfg = loadConfig(); + const team = resolveTeam(cfg, p.teamId); + if (!team) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown team: "${p.teamId}"`), + ); + return; + } + + const leadAgentId = normalizeAgentId(team.lead); + const teamContext = buildTeamContextPrompt({ cfg, team }); + const idem = p.idempotencyKey || crypto.randomUUID(); + const runId = idem; + const sessionKey = + p.sessionKey || `agent:${leadAgentId}:team:${team.id}:${crypto.randomUUID()}`; + + const accepted = { + runId, + teamId: team.id, + leadAgentId, + status: "accepted" as const, + }; + respond(true, accepted, undefined, { runId }); + + void agentCommand( + { + message: p.message, + agentId: leadAgentId, + sessionKey, + runId, + deliver: p.deliver ?? false, + extraSystemPrompt: teamContext, + messageChannel: "webchat", + bestEffortDeliver: false, + thinking: p.thinking, + timeout: p.timeout?.toString(), + channel: p.channel, + to: p.to, + }, + defaultRuntime, + context.deps, + ) + .then((result) => { + const payload = { + runId, + teamId: team.id, + leadAgentId, + status: "ok" as const, + result, + }; + respond(true, payload, undefined, { runId }); + }) + .catch((err) => { + const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); + const payload = { + runId, + teamId: team.id, + leadAgentId, + status: "error" as const, + summary: String(err), + }; + respond(false, payload, error, { + runId, + error: formatForLog(err), + }); + }); + }, + + "teams.status": ({ params, respond }) => { + if (!validateTeamsStatusParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid teams.status params: ${formatValidationErrors(validateTeamsStatusParams.errors)}`, + ), + ); + return; + } + const p = params as { teamId: string }; + const cfg = loadConfig(); + const team = resolveTeam(cfg, p.teamId); + if (!team) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown team: "${p.teamId}"`), + ); + return; + } + + const leadAgentId = normalizeAgentId(team.lead); + const leadKey = `agent:${leadAgentId}`; + const leadActive = countActiveRunsForSession(leadKey); + let totalActive = leadActive; + + const members: Array<{ agentId: string; activeRuns: number }> = []; + for (const memberId of team.members) { + const id = normalizeAgentId(memberId); + const memberKey = `agent:${id}`; + const active = countActiveRunsForSession(memberKey); + totalActive += active; + members.push({ agentId: id, activeRuns: active }); + } + + respond( + true, + { + teamId: team.id, + name: team.name, + leadAgentId, + leadActiveRuns: leadActive, + totalActiveRuns: totalActive, + members, + }, + undefined, + ); + }, +}; diff --git a/src/gateway/teams-http.ts b/src/gateway/teams-http.ts new file mode 100644 index 000000000000..f2f947f738d6 --- /dev/null +++ b/src/gateway/teams-http.ts @@ -0,0 +1,170 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import crypto from "node:crypto"; +import type { AuthRateLimiter } from "./auth-rate-limit.js"; +import type { ResolvedGatewayAuth } from "./auth.js"; +import { countActiveRunsForSession } from "../agents/subagent-registry.js"; +import { createDefaultDeps } from "../cli/deps.js"; +import { agentCommand } from "../commands/agent.js"; +import { loadConfig } from "../config/config.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import { defaultRuntime } from "../runtime.js"; +import { resolveTeam, listTeamsResolved, buildTeamContextPrompt } from "../teams/resolve.js"; +import { authorizeGatewayBearerRequestOrReply } from "./http-auth-helpers.js"; +import { + readJsonBodyOrError, + sendJson, + sendMethodNotAllowed, + sendInvalidRequest, +} from "./http-common.js"; + +const TEAMS_PREFIX = "/v1/teams"; + +export async function handleTeamsHttpRequest( + req: IncomingMessage, + res: ServerResponse, + opts: { + auth: ResolvedGatewayAuth; + trustedProxies?: string[]; + rateLimiter?: AuthRateLimiter; + }, +): Promise { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + if (!url.pathname.startsWith(TEAMS_PREFIX)) { + return false; + } + + const authed = await authorizeGatewayBearerRequestOrReply({ + req, + res, + auth: opts.auth, + trustedProxies: opts.trustedProxies, + rateLimiter: opts.rateLimiter, + }); + if (!authed) { + return true; + } + + const subpath = url.pathname.slice(TEAMS_PREFIX.length); + + // GET /v1/teams + if ((subpath === "" || subpath === "/") && req.method === "GET") { + const cfg = loadConfig(); + const teams = listTeamsResolved(cfg); + sendJson(res, 200, { teams }); + return true; + } + + // Match /v1/teams/:id/run or /v1/teams/:id/status + const match = subpath.match(/^\/([^/]+)\/(run|status)$/); + if (!match) { + sendInvalidRequest(res, "unknown teams endpoint"); + return true; + } + + const teamId = decodeURIComponent(match[1]); + const action = match[2]; + + const cfg = loadConfig(); + const team = resolveTeam(cfg, teamId); + if (!team) { + sendJson(res, 404, { error: { type: "not_found", message: `unknown team: "${teamId}"` } }); + return true; + } + + // POST /v1/teams/:id/run + if (action === "run") { + if (req.method !== "POST") { + sendMethodNotAllowed(res, "POST"); + return true; + } + const bodyRaw = await readJsonBodyOrError(req, res, 1024 * 1024); + if (bodyRaw === undefined) { + return true; + } + const body = (bodyRaw ?? {}) as Record; + const message = typeof body.message === "string" ? body.message.trim() : ""; + if (!message) { + sendInvalidRequest(res, "message is required"); + return true; + } + + const leadAgentId = normalizeAgentId(team.lead); + const teamContext = buildTeamContextPrompt({ cfg, team }); + const idem = + typeof body.idempotencyKey === "string" && body.idempotencyKey.trim() + ? body.idempotencyKey.trim() + : crypto.randomUUID(); + const sessionKey = + typeof body.sessionKey === "string" && body.sessionKey.trim() + ? body.sessionKey.trim() + : `agent:${leadAgentId}:team:${team.id}:${crypto.randomUUID()}`; + const deliver = body.deliver === true; + const timeout = + typeof body.timeout === "number" && Number.isFinite(body.timeout) ? body.timeout : undefined; + const thinking = + typeof body.thinking === "string" && body.thinking.trim() ? body.thinking.trim() : undefined; + + const runId = idem; + + // Return 202 immediately. + sendJson(res, 202, { + runId, + teamId: team.id, + leadAgentId, + status: "accepted", + }); + + // Run asynchronously. + void agentCommand( + { + message, + agentId: leadAgentId, + sessionKey, + runId, + deliver, + extraSystemPrompt: teamContext, + messageChannel: "webchat", + bestEffortDeliver: false, + thinking, + timeout: timeout?.toString(), + }, + defaultRuntime, + createDefaultDeps(), + ); + + return true; + } + + // GET /v1/teams/:id/status + if (action === "status") { + if (req.method !== "GET") { + sendMethodNotAllowed(res, "GET"); + return true; + } + const leadAgentId = normalizeAgentId(team.lead); + const leadKey = `agent:${leadAgentId}`; + const leadActive = countActiveRunsForSession(leadKey); + let totalActive = leadActive; + + const members: Array<{ agentId: string; activeRuns: number }> = []; + for (const memberId of team.members) { + const id = normalizeAgentId(memberId); + const memberKey = `agent:${id}`; + const active = countActiveRunsForSession(memberKey); + totalActive += active; + members.push({ agentId: id, activeRuns: active }); + } + + sendJson(res, 200, { + teamId: team.id, + name: team.name, + leadAgentId, + leadActiveRuns: leadActive, + totalActiveRuns: totalActive, + members, + }); + return true; + } + + return false; +} diff --git a/src/teams/auto-wire.test.ts b/src/teams/auto-wire.test.ts new file mode 100644 index 000000000000..6a958c182a53 --- /dev/null +++ b/src/teams/auto-wire.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveEffectiveAllowAgents, resolveTeamLeadToolsAlsoAllow } from "./auto-wire.js"; + +function makeConfig(overrides?: Partial): OpenClawConfig { + return { + agents: { + list: [ + { id: "eng-lead", name: "Engineering Lead" }, + { id: "eng-backend", name: "Backend Engineer" }, + { id: "eng-frontend", name: "Frontend Engineer" }, + { id: "solo-agent" }, + ], + }, + teams: [ + { + id: "engineering", + name: "Engineering Team", + lead: "eng-lead", + members: ["eng-backend", "eng-frontend"], + }, + ], + ...overrides, + }; +} + +describe("resolveEffectiveAllowAgents", () => { + it("extends existing allowAgents with team members", () => { + const cfg = makeConfig(); + const result = resolveEffectiveAllowAgents(cfg, "eng-lead", ["some-other-agent"]); + expect(result).toContain("some-other-agent"); + expect(result).toContain("eng-backend"); + expect(result).toContain("eng-frontend"); + }); + + it("returns team members when no configured allow list", () => { + const cfg = makeConfig(); + const result = resolveEffectiveAllowAgents(cfg, "eng-lead", undefined); + expect(result).toContain("eng-backend"); + expect(result).toContain("eng-frontend"); + expect(result).toHaveLength(2); + }); + + it("returns original list for non-leads", () => { + const cfg = makeConfig(); + const result = resolveEffectiveAllowAgents(cfg, "solo-agent", ["some-agent"]); + expect(result).toEqual(["some-agent"]); + }); + + it("returns empty array for non-leads with no configured list", () => { + const cfg = makeConfig(); + const result = resolveEffectiveAllowAgents(cfg, "solo-agent", undefined); + expect(result).toEqual([]); + }); + + it("deduplicates when configured and team members overlap", () => { + const cfg = makeConfig(); + const result = resolveEffectiveAllowAgents(cfg, "eng-lead", ["eng-backend", "extra"]); + expect(result.filter((id) => id === "eng-backend")).toHaveLength(1); + expect(result).toContain("extra"); + expect(result).toContain("eng-frontend"); + }); +}); + +describe("resolveTeamLeadToolsAlsoAllow", () => { + it("adds sessions_spawn and agents_list for leads", () => { + const cfg = makeConfig(); + const result = resolveTeamLeadToolsAlsoAllow(cfg, "eng-lead", undefined); + expect(result).toContain("sessions_spawn"); + expect(result).toContain("agents_list"); + }); + + it("preserves existing also-allow entries for leads", () => { + const cfg = makeConfig(); + const result = resolveTeamLeadToolsAlsoAllow(cfg, "eng-lead", ["memory_search"]); + expect(result).toContain("memory_search"); + expect(result).toContain("sessions_spawn"); + expect(result).toContain("agents_list"); + }); + + it("returns undefined for non-leads", () => { + const cfg = makeConfig(); + expect(resolveTeamLeadToolsAlsoAllow(cfg, "solo-agent", undefined)).toBeUndefined(); + }); + + it("returns existing list unchanged for non-leads", () => { + const cfg = makeConfig(); + const existing = ["tool_a"]; + expect(resolveTeamLeadToolsAlsoAllow(cfg, "solo-agent", existing)).toBe(existing); + }); +}); diff --git a/src/teams/auto-wire.ts b/src/teams/auto-wire.ts new file mode 100644 index 000000000000..72c2b1478c59 --- /dev/null +++ b/src/teams/auto-wire.ts @@ -0,0 +1,41 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { resolveTeamAllowAgents, isTeamLead } from "./resolve.js"; + +/** + * Extends allowAgents with team member IDs if this agent is a team lead. + * Runtime-only — does NOT persist to config file. + */ +export function resolveEffectiveAllowAgents( + cfg: OpenClawConfig, + agentId: string, + configured: string[] | undefined, +): string[] { + const teamMembers = resolveTeamAllowAgents(cfg, agentId); + if (teamMembers.length === 0) { + return configured ?? []; + } + const base = configured ?? []; + const merged = new Set(base); + for (const id of teamMembers) { + merged.add(id); + } + return Array.from(merged); +} + +/** + * For team leads, ensures sessions_spawn and agents_list are in the tools.alsoAllow list. + */ +export function resolveTeamLeadToolsAlsoAllow( + cfg: OpenClawConfig, + agentId: string, + existingAlsoAllow: string[] | undefined, +): string[] | undefined { + if (!isTeamLead(cfg, agentId)) { + return existingAlsoAllow; + } + const base = existingAlsoAllow ?? []; + const merged = new Set(base); + merged.add("sessions_spawn"); + merged.add("agents_list"); + return Array.from(merged); +} diff --git a/src/teams/resolve.test.ts b/src/teams/resolve.test.ts new file mode 100644 index 000000000000..537988816546 --- /dev/null +++ b/src/teams/resolve.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + resolveTeam, + listTeamIds, + listTeamsResolved, + resolveTeamAllowAgents, + isTeamLead, + buildTeamContextPrompt, +} from "./resolve.js"; + +function makeConfig(overrides?: Partial): OpenClawConfig { + return { + agents: { + list: [ + { id: "eng-lead", name: "Engineering Lead" }, + { id: "eng-backend", name: "Backend Engineer" }, + { id: "eng-frontend", name: "Frontend Engineer" }, + { id: "eng-reviewer", name: "Code Reviewer" }, + { id: "solo-agent" }, + ], + }, + teams: [ + { + id: "engineering", + name: "Engineering Team", + lead: "eng-lead", + members: ["eng-backend", "eng-frontend", "eng-reviewer"], + description: "Handles all engineering tasks", + }, + ], + ...overrides, + }; +} + +describe("resolveTeam", () => { + it("finds a team by ID", () => { + const cfg = makeConfig(); + const team = resolveTeam(cfg, "engineering"); + expect(team).toBeDefined(); + expect(team?.id).toBe("engineering"); + expect(team?.lead).toBe("eng-lead"); + }); + + it("returns undefined for unknown team", () => { + const cfg = makeConfig(); + expect(resolveTeam(cfg, "nonexistent")).toBeUndefined(); + }); + + it("is case-insensitive", () => { + const cfg = makeConfig(); + expect(resolveTeam(cfg, "ENGINEERING")).toBeDefined(); + }); + + it("returns undefined when no teams configured", () => { + const cfg: OpenClawConfig = { agents: { list: [{ id: "main" }] } }; + expect(resolveTeam(cfg, "engineering")).toBeUndefined(); + }); +}); + +describe("listTeamIds", () => { + it("lists all team IDs", () => { + const cfg = makeConfig(); + expect(listTeamIds(cfg)).toEqual(["engineering"]); + }); + + it("returns empty array when no teams configured", () => { + const cfg: OpenClawConfig = {}; + expect(listTeamIds(cfg)).toEqual([]); + }); +}); + +describe("listTeamsResolved", () => { + it("enriches teams with agent names", () => { + const cfg = makeConfig(); + const resolved = listTeamsResolved(cfg); + expect(resolved).toHaveLength(1); + expect(resolved[0].lead.name).toBe("Engineering Lead"); + expect(resolved[0].members).toHaveLength(3); + expect(resolved[0].members[0].name).toBe("Backend Engineer"); + }); + + it("returns empty array when no teams configured", () => { + const cfg: OpenClawConfig = {}; + expect(listTeamsResolved(cfg)).toEqual([]); + }); +}); + +describe("resolveTeamAllowAgents", () => { + it("returns member IDs for team leads", () => { + const cfg = makeConfig(); + const members = resolveTeamAllowAgents(cfg, "eng-lead"); + expect(members).toContain("eng-backend"); + expect(members).toContain("eng-frontend"); + expect(members).toContain("eng-reviewer"); + }); + + it("returns empty for non-leads", () => { + const cfg = makeConfig(); + expect(resolveTeamAllowAgents(cfg, "solo-agent")).toEqual([]); + }); + + it("returns empty when no teams configured", () => { + const cfg: OpenClawConfig = {}; + expect(resolveTeamAllowAgents(cfg, "any")).toEqual([]); + }); +}); + +describe("isTeamLead", () => { + it("identifies lead agents", () => { + const cfg = makeConfig(); + expect(isTeamLead(cfg, "eng-lead")).toBe(true); + }); + + it("returns false for non-leads", () => { + const cfg = makeConfig(); + expect(isTeamLead(cfg, "eng-backend")).toBe(false); + expect(isTeamLead(cfg, "solo-agent")).toBe(false); + }); + + it("returns false when no teams configured", () => { + const cfg: OpenClawConfig = {}; + expect(isTeamLead(cfg, "eng-lead")).toBe(false); + }); +}); + +describe("buildTeamContextPrompt", () => { + it("produces correct system prompt", () => { + const cfg = makeConfig(); + const team = cfg.teams![0]; + const prompt = buildTeamContextPrompt({ cfg, team }); + expect(prompt).toContain("# Team Context"); + expect(prompt).toContain('"Engineering Team"'); + expect(prompt).toContain("eng-backend (Backend Engineer)"); + expect(prompt).toContain("eng-frontend (Frontend Engineer)"); + expect(prompt).toContain("eng-reviewer (Code Reviewer)"); + expect(prompt).toContain("Team purpose: Handles all engineering tasks"); + expect(prompt).toContain("sessions_spawn"); + }); + + it("omits description line when not set", () => { + const cfg = makeConfig({ + teams: [ + { + id: "minimal", + lead: "eng-lead", + members: ["eng-backend"], + }, + ], + }); + const team = cfg.teams![0]; + const prompt = buildTeamContextPrompt({ cfg, team }); + expect(prompt).not.toContain("Team purpose:"); + }); +}); diff --git a/src/teams/resolve.ts b/src/teams/resolve.ts new file mode 100644 index 000000000000..64a4fc384643 --- /dev/null +++ b/src/teams/resolve.ts @@ -0,0 +1,106 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { TeamConfig } from "../config/types.teams.js"; +import { resolveAgentConfig } from "../agents/agent-scope.js"; +import { normalizeAgentId } from "../routing/session-key.js"; + +export function resolveTeam(cfg: OpenClawConfig, teamId: string): TeamConfig | undefined { + const teams = cfg.teams; + if (!Array.isArray(teams)) { + return undefined; + } + const normalized = teamId.trim().toLowerCase(); + return teams.find((t) => t.id.trim().toLowerCase() === normalized); +} + +export function listTeamIds(cfg: OpenClawConfig): string[] { + const teams = cfg.teams; + if (!Array.isArray(teams)) { + return []; + } + return teams.map((t) => t.id); +} + +export type ResolvedTeam = { + id: string; + name?: string; + description?: string; + lead: { id: string; name?: string }; + members: Array<{ id: string; name?: string }>; +}; + +export function listTeamsResolved(cfg: OpenClawConfig): ResolvedTeam[] { + const teams = cfg.teams; + if (!Array.isArray(teams)) { + return []; + } + return teams.map((team) => { + const leadAgent = resolveAgentConfig(cfg, team.lead); + return { + id: team.id, + name: team.name, + description: team.description, + lead: { id: normalizeAgentId(team.lead), name: leadAgent?.name }, + members: team.members.map((memberId) => { + const memberAgent = resolveAgentConfig(cfg, memberId); + return { id: normalizeAgentId(memberId), name: memberAgent?.name }; + }), + }; + }); +} + +export function resolveTeamAllowAgents(cfg: OpenClawConfig, agentId: string): string[] { + const teams = cfg.teams; + if (!Array.isArray(teams)) { + return []; + } + const normalized = normalizeAgentId(agentId); + const memberIds: string[] = []; + for (const team of teams) { + if (normalizeAgentId(team.lead) === normalized) { + for (const memberId of team.members) { + memberIds.push(normalizeAgentId(memberId)); + } + } + } + return memberIds; +} + +export function isTeamLead(cfg: OpenClawConfig, agentId: string): boolean { + const teams = cfg.teams; + if (!Array.isArray(teams)) { + return false; + } + const normalized = normalizeAgentId(agentId); + return teams.some((team) => normalizeAgentId(team.lead) === normalized); +} + +export function buildTeamContextPrompt(opts: { cfg: OpenClawConfig; team: TeamConfig }): string { + const { cfg, team } = opts; + const teamName = team.name || team.id; + const lines: string[] = []; + + lines.push("# Team Context"); + lines.push(`You are the lead of the "${teamName}" team.`); + lines.push("Your team members:"); + + for (const memberId of team.members) { + const agent = resolveAgentConfig(cfg, memberId); + const name = agent?.name; + const id = normalizeAgentId(memberId); + if (name) { + lines.push(`- ${id} (${name})`); + } else { + lines.push(`- ${id}`); + } + } + + if (team.description) { + lines.push(""); + lines.push(`Team purpose: ${team.description}`); + } + + lines.push(""); + lines.push("Route tasks to the appropriate team member using sessions_spawn."); + + return lines.join("\n"); +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 96bfba6a859c..069a4631c377 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -50,6 +50,7 @@ import { updateSkillEdit, updateSkillEnabled, } from "./controllers/skills.ts"; +import { loadTeams, sendTeamMessage } from "./controllers/teams.ts"; import { icons } from "./icons.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; import { renderAgents } from "./views/agents.ts"; @@ -66,6 +67,7 @@ import { renderNodes } from "./views/nodes.ts"; import { renderOverview } from "./views/overview.ts"; import { renderSessions } from "./views/sessions.ts"; import { renderSkills } from "./views/skills.ts"; +import { renderTeams } from "./views/teams.ts"; const AVATAR_DATA_RE = /^data:/i; const AVATAR_HTTP_RE = /^https?:\/\//i; @@ -704,6 +706,33 @@ export function renderApp(state: AppViewState) { : nothing } + ${ + state.tab === "teams" + ? renderTeams({ + loading: state.teamsLoading, + error: state.teamsError, + teamsList: state.teamsList, + selectedTeamId: state.teamsSelectedId, + activePanel: state.teamsPanel, + chatMessage: state.teamsChatMessage, + chatSending: state.teamsChatSending, + chatMessages: state.teamsChatMessages, + agentIdentityById: state.agentIdentityById, + onRefresh: () => loadTeams(state), + onSelectTeam: (teamId) => { + state.teamsSelectedId = teamId; + }, + onSelectPanel: (panel) => { + state.teamsPanel = panel; + }, + onChatMessageChange: (msg) => { + state.teamsChatMessage = msg; + }, + onSendChat: (teamId) => sendTeamMessage(state, teamId), + }) + : nothing + } + ${ state.tab === "skills" ? renderSkills({ diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 00a67d7e977a..5d88b417b26e 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -22,6 +22,7 @@ import { loadNodes } from "./controllers/nodes.ts"; import { loadPresence } from "./controllers/presence.ts"; import { loadSessions } from "./controllers/sessions.ts"; import { loadSkills } from "./controllers/skills.ts"; +import { loadTeams } from "./controllers/teams.ts"; import { inferBasePathFromPathname, normalizeBasePath, @@ -219,6 +220,9 @@ export async function refreshActiveTab(host: SettingsHost) { } } } + if (host.tab === "teams") { + await loadTeams(host as unknown as OpenClawApp); + } if (host.tab === "nodes") { await loadNodes(host as unknown as OpenClawApp); await loadDevices(host as unknown as OpenClawApp); diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 8f565cfe7652..5624117dcdff 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -13,6 +13,7 @@ import type { AgentsListResult, AgentsFilesListResult, AgentIdentityResult, + TeamsListResult, ChannelsStatusSnapshot, ConfigSnapshot, ConfigUiHints, @@ -147,6 +148,15 @@ export type AppViewState = { agentIdentityLoading: boolean; agentIdentityError: string | null; agentIdentityById: Record; + teamsLoading: boolean; + teamsList: TeamsListResult | null; + teamsError: string | null; + teamsSelectedId: string | null; + teamsPanel: "overview" | "chat" | "status"; + teamsChatMessage: string; + teamsChatSending: boolean; + teamsChatMessages: Array<{ role: "user" | "assistant"; text: string; ts: number }>; + teamsChatRunId: string | null; agentSkillsLoading: boolean; agentSkillsError: string | null; agentSkillsReport: SkillStatusReport | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 481e2258bcb3..7d7e0a95fb79 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -13,6 +13,7 @@ import type { AgentsListResult, AgentsFilesListResult, AgentIdentityResult, + TeamsListResult, ConfigSnapshot, ConfigUiHints, CronJob, @@ -229,6 +230,16 @@ export class OpenClawApp extends LitElement { @state() agentSkillsReport: SkillStatusReport | null = null; @state() agentSkillsAgentId: string | null = null; + @state() teamsLoading = false; + @state() teamsList: TeamsListResult | null = null; + @state() teamsError: string | null = null; + @state() teamsSelectedId: string | null = null; + @state() teamsPanel: "overview" | "chat" | "status" = "overview"; + @state() teamsChatMessage = ""; + @state() teamsChatSending = false; + @state() teamsChatMessages: Array<{ role: "user" | "assistant"; text: string; ts: number }> = []; + @state() teamsChatRunId: string | null = null; + @state() sessionsLoading = false; @state() sessionsResult: SessionsListResult | null = null; @state() sessionsError: string | null = null; diff --git a/ui/src/ui/controllers/teams.ts b/ui/src/ui/controllers/teams.ts new file mode 100644 index 000000000000..d6d0161e2188 --- /dev/null +++ b/ui/src/ui/controllers/teams.ts @@ -0,0 +1,98 @@ +import type { GatewayBrowserClient } from "../gateway.ts"; +import type { TeamsListResult, TeamsStatusResult } from "../types.ts"; + +export type TeamsState = { + client: GatewayBrowserClient | null; + connected: boolean; + teamsLoading: boolean; + teamsError: string | null; + teamsList: TeamsListResult | null; + teamsSelectedId: string | null; + teamsChatMessage: string; + teamsChatSending: boolean; + teamsChatMessages: Array<{ role: "user" | "assistant"; text: string; ts: number }>; + teamsChatRunId: string | null; +}; + +export async function loadTeams(state: TeamsState) { + if (!state.client || !state.connected) { + return; + } + if (state.teamsLoading) { + return; + } + state.teamsLoading = true; + state.teamsError = null; + try { + const res = await state.client.request("teams.list", {}); + if (res) { + state.teamsList = res; + const selected = state.teamsSelectedId; + const known = res.teams.some((t) => t.id === selected); + if (!selected || !known) { + state.teamsSelectedId = res.teams[0]?.id ?? null; + } + } + } catch (err) { + state.teamsError = String(err); + } finally { + state.teamsLoading = false; + } +} + +export async function loadTeamStatus( + state: TeamsState, + teamId: string, +): Promise { + if (!state.client || !state.connected) { + return null; + } + try { + return await state.client.request("teams.status", { teamId }); + } catch { + return null; + } +} + +export async function sendTeamMessage(state: TeamsState, teamId: string) { + const message = state.teamsChatMessage.trim(); + if (!message || !state.client || !state.connected) { + return; + } + state.teamsChatSending = true; + const idempotencyKey = crypto.randomUUID(); + state.teamsChatMessages = [ + ...state.teamsChatMessages, + { role: "user" as const, text: message, ts: Date.now() }, + ]; + state.teamsChatMessage = ""; + try { + const res = await state.client.request<{ + runId: string; + teamId: string; + leadAgentId: string; + status: string; + }>("teams.run", { + teamId, + message, + idempotencyKey, + deliver: false, + }); + state.teamsChatRunId = res?.runId ?? null; + state.teamsChatMessages = [ + ...state.teamsChatMessages, + { + role: "assistant" as const, + text: "Task accepted. Check status for updates.", + ts: Date.now(), + }, + ]; + } catch (err) { + state.teamsChatMessages = [ + ...state.teamsChatMessages, + { role: "assistant" as const, text: `Error: ${String(err)}`, ts: Date.now() }, + ]; + } finally { + state.teamsChatSending = false; + } +} diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts index c4208fb50c43..bd0c52449109 100644 --- a/ui/src/ui/navigation.ts +++ b/ui/src/ui/navigation.ts @@ -6,12 +6,13 @@ export const TAB_GROUPS = [ label: "Control", tabs: ["overview", "channels", "instances", "sessions", "usage", "cron"], }, - { label: "Agent", tabs: ["agents", "skills", "nodes"] }, + { label: "Agent", tabs: ["agents", "teams", "skills", "nodes"] }, { label: "Settings", tabs: ["config", "debug", "logs"] }, ] as const; export type Tab = | "agents" + | "teams" | "overview" | "channels" | "instances" @@ -27,6 +28,7 @@ export type Tab = const TAB_PATHS: Record = { agents: "/agents", + teams: "/teams", overview: "/overview", channels: "/channels", instances: "/instances", @@ -126,6 +128,8 @@ export function iconForTab(tab: Tab): IconName { switch (tab) { case "agents": return "folder"; + case "teams": + return "brain"; case "chat": return "messageSquare"; case "overview": @@ -159,6 +163,8 @@ export function titleForTab(tab: Tab) { switch (tab) { case "agents": return "Agents"; + case "teams": + return "Teams"; case "overview": return "Overview"; case "channels": @@ -192,6 +198,8 @@ export function subtitleForTab(tab: Tab) { switch (tab) { case "agents": return "Manage agent workspaces, tools, and identities."; + case "teams": + return "View and interact with agent teams."; case "overview": return "Gateway status, entry points, and a fast health read."; case "channels": diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 2d53a9ccbb52..e85b90665de6 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -554,6 +554,25 @@ export type StatusSummary = Record; export type HealthSnapshot = Record; +export type TeamSummary = { + id: string; + name?: string; + lead: { id: string; name?: string }; + members: Array<{ id: string; name?: string }>; + description?: string; +}; + +export type TeamsListResult = { teams: TeamSummary[] }; + +export type TeamsStatusResult = { + teamId: string; + name?: string; + leadAgentId: string; + leadActiveRuns: number; + totalActiveRuns: number; + members: Array<{ agentId: string; activeRuns: number }>; +}; + export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"; export type LogEntry = { diff --git a/ui/src/ui/views/teams.ts b/ui/src/ui/views/teams.ts new file mode 100644 index 000000000000..fc996f0b9826 --- /dev/null +++ b/ui/src/ui/views/teams.ts @@ -0,0 +1,237 @@ +import { html, nothing } from "lit"; +import type { AgentIdentityResult, TeamsListResult } from "../types.ts"; + +export type TeamsPanel = "overview" | "chat" | "status"; + +export type TeamsProps = { + loading: boolean; + error: string | null; + teamsList: TeamsListResult | null; + selectedTeamId: string | null; + activePanel: TeamsPanel; + chatMessage: string; + chatSending: boolean; + chatMessages: Array<{ role: "user" | "assistant"; text: string; ts: number }>; + agentIdentityById: Record; + onRefresh: () => void; + onSelectTeam: (teamId: string) => void; + onSelectPanel: (panel: TeamsPanel) => void; + onChatMessageChange: (msg: string) => void; + onSendChat: (teamId: string) => void; +}; + +function renderTeamsTabs(active: TeamsPanel, onSelect: (panel: TeamsPanel) => void) { + const tabs: Array<{ key: TeamsPanel; label: string }> = [ + { key: "overview", label: "Overview" }, + { key: "chat", label: "Chat" }, + { key: "status", label: "Status" }, + ]; + return html` +
+ ${tabs.map( + (t) => html` + + `, + )} +
+ `; +} + +export function renderTeams(props: TeamsProps) { + const teams = props.teamsList?.teams ?? []; + const selectedId = props.selectedTeamId ?? teams[0]?.id ?? null; + const selectedTeam = selectedId ? (teams.find((t) => t.id === selectedId) ?? null) : null; + + return html` +
+
+
+
+
Teams
+
${teams.length} configured.
+
+ +
+ ${ + props.error + ? html`
${props.error}
` + : nothing + } +
+ ${ + teams.length === 0 + ? html` +
No teams configured.
+ ` + : teams.map( + (team) => html` + + `, + ) + } +
+
+
+ ${ + !selectedTeam + ? html` +
+
Select a team
+
Pick a team to inspect or interact with.
+
+ ` + : html` +
+
${selectedTeam.name || selectedTeam.id}
+ ${selectedTeam.description ? html`
${selectedTeam.description}
` : nothing} +
+ ${renderTeamsTabs(props.activePanel, (panel) => props.onSelectPanel(panel))} + ${props.activePanel === "overview" ? renderTeamOverview(selectedTeam, props.agentIdentityById) : nothing} + ${props.activePanel === "chat" ? renderTeamChat(selectedTeam.id, props) : nothing} + ${props.activePanel === "status" ? renderTeamStatus(selectedTeam) : nothing} + ` + } +
+
+ `; +} + +function renderTeamOverview( + team: NonNullable, + identityById: Record, +) { + const leadIdentity = identityById[team.lead.id]; + return html` +
+
Lead Agent
+
+
+ ${leadIdentity?.emoji || (team.lead.name || team.lead.id).slice(0, 1).toUpperCase()} +
+
+
${team.lead.name || team.lead.id}
+
${team.lead.id}
+
+ lead +
+
+
+
Members
+ ${team.members.map((member) => { + const memberIdentity = identityById[member.id]; + return html` +
+
+ ${memberIdentity?.emoji || (member.name || member.id).slice(0, 1).toUpperCase()} +
+
+
${member.name || member.id}
+
${member.id}
+
+
+ `; + })} +
+ `; +} + +function renderTeamChat(teamId: string, props: TeamsProps) { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + props.onSendChat(teamId); + } + }; + return html` +
+
Chat with Team
+
+ ${ + props.chatMessages.length === 0 + ? html` +
No messages yet. Send a task to the team.
+ ` + : props.chatMessages.map( + (msg) => html` +
+ + ${msg.role === "user" ? "You" : "Team"} + +
+ ${msg.text} +
+
+ `, + ) + } +
+
+ + +
+
+ `; +} + +function renderTeamStatus(team: NonNullable) { + return html` +
+
Team Status
+
+ Active runs across team members. Refresh the Teams tab to update. +
+
+
+
${team.lead.name || team.lead.id}
+
${team.lead.id}
+
+ lead +
+ ${team.members.map( + (member) => html` +
+
+
${member.name || member.id}
+
${member.id}
+
+
+ `, + )} +
+ `; +} From aa84ea4769d47411aeee6400c61a9f9942e71eae Mon Sep 17 00:00:00 2001 From: Achuth Karakkat Date: Thu, 19 Feb 2026 08:20:45 +0530 Subject: [PATCH 4/6] feat(ui): add teams to chat agent picker with streaming responses --- src/gateway/server-methods/teams.ts | 18 ++++- ui/src/ui/app-gateway.ts | 2 + ui/src/ui/app-render.helpers.ts | 107 +++++++++++++++++++++++----- ui/src/ui/app-view-state.ts | 1 + ui/src/ui/app.ts | 1 + ui/src/ui/controllers/chat.test.ts | 1 + ui/src/ui/controllers/chat.ts | 25 +++++-- 7 files changed, 128 insertions(+), 27 deletions(-) diff --git a/src/gateway/server-methods/teams.ts b/src/gateway/server-methods/teams.ts index f2883db75d27..4734f1189c89 100644 --- a/src/gateway/server-methods/teams.ts +++ b/src/gateway/server-methods/teams.ts @@ -3,9 +3,11 @@ import type { GatewayRequestHandlers } from "./types.js"; import { countActiveRunsForSession } from "../../agents/subagent-registry.js"; import { agentCommand } from "../../commands/agent.js"; import { loadConfig } from "../../config/config.js"; +import { registerAgentRunContext } from "../../infra/agent-events.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import { defaultRuntime } from "../../runtime.js"; import { resolveTeam, listTeamsResolved, buildTeamContextPrompt } from "../../teams/resolve.js"; +import { GATEWAY_CLIENT_CAPS, hasGatewayClientCap } from "../protocol/client-info.js"; import { ErrorCodes, errorShape, @@ -34,7 +36,7 @@ export const teamsHandlers: GatewayRequestHandlers = { respond(true, { teams }, undefined); }, - "teams.run": async ({ params, respond, context }) => { + "teams.run": async ({ params, respond, context, client }) => { if (!validateTeamsRunParams(params)) { respond( false, @@ -75,6 +77,20 @@ export const teamsHandlers: GatewayRequestHandlers = { const sessionKey = p.sessionKey || `agent:${leadAgentId}:team:${team.id}:${crypto.randomUUID()}`; + // Register with the streaming pipeline so delta/final events are + // broadcast to connected clients (same mechanism as chat.send). + registerAgentRunContext(runId, { sessionKey }); + context.addChatRun(runId, { sessionKey, clientRunId: runId }); + + const connId = typeof client?.connId === "string" ? client.connId : undefined; + const wantsToolEvents = hasGatewayClientCap( + client?.connect?.caps, + GATEWAY_CLIENT_CAPS.TOOL_EVENTS, + ); + if (connId && wantsToolEvents) { + context.registerToolEventRecipient(runId, connId); + } + const accepted = { runId, teamId: team.id, diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 12b2c7b6d9ec..d1404e201004 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -26,6 +26,7 @@ import { } from "./controllers/exec-approval.ts"; import { loadNodes } from "./controllers/nodes.ts"; import { loadSessions } from "./controllers/sessions.ts"; +import { loadTeams } from "./controllers/teams.ts"; import { GatewayBrowserClient } from "./gateway.ts"; type GatewayHost = { @@ -145,6 +146,7 @@ export function connectGateway(host: GatewayHost) { resetToolStream(host as unknown as Parameters[0]); void loadAssistantIdentity(host as unknown as OpenClawApp); void loadAgents(host as unknown as OpenClawApp); + void loadTeams(host as unknown as OpenClawApp); void loadNodes(host as unknown as OpenClawApp, { quiet: true }); void loadDevices(host as unknown as OpenClawApp, { quiet: true }); void refreshActiveTab(host as unknown as Parameters[0]); diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 865489309bc0..29945efa35dd 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -3,7 +3,7 @@ import { repeat } from "lit/directives/repeat.js"; import type { AppViewState } from "./app-view-state.ts"; import type { ThemeTransitionContext } from "./theme-transition.ts"; import type { ThemeMode } from "./theme.ts"; -import type { GatewayAgentRow, SessionsListResult } from "./types.ts"; +import type { GatewayAgentRow, SessionsListResult, TeamSummary } from "./types.ts"; import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js"; import { refreshChat, refreshChatAvatar } from "./app-chat.ts"; import { syncUrlWithSessionKey } from "./app-settings.ts"; @@ -82,12 +82,17 @@ export function renderTab(state: AppViewState, tab: Tab) { `; } -function resolveCurrentAgentId(sessionKey: string, agentsList: AppViewState["agentsList"]): string { - const parsed = parseAgentSessionKey(sessionKey); +const TEAM_PREFIX = "team:"; + +function resolveCurrentPickerValue(state: AppViewState): string { + if (state.chatTeamId) { + return `${TEAM_PREFIX}${state.chatTeamId}`; + } + const parsed = parseAgentSessionKey(state.sessionKey); if (parsed?.agentId) { return parsed.agentId; } - return agentsList?.defaultId ?? "main"; + return state.agentsList?.defaultId ?? "main"; } function buildAgentSessionKey(agentId: string, mainKey?: string): string { @@ -119,39 +124,103 @@ function switchToAgent(state: AppViewState, agentId: string) { void refreshChatAvatar(state as unknown as Parameters[0]); } +function switchToTeam(state: AppViewState, teamId: string, team: TeamSummary) { + const leadAgentId = team.lead.id; + const nextSessionKey = `agent:${leadAgentId}:team:${teamId}:main`; + state.chatTeamId = teamId; + state.sessionKey = nextSessionKey; + state.chatMessage = ""; + state.chatStream = null; + (state as unknown as OpenClawApp).chatStreamStartedAt = null; + state.chatRunId = null; + (state as unknown as OpenClawApp).resetToolStream(); + (state as unknown as OpenClawApp).resetChatScroll(); + state.applySettings({ + ...state.settings, + sessionKey: nextSessionKey, + lastActiveSessionKey: nextSessionKey, + }); + void state.loadAssistantIdentity(); + syncUrlWithSessionKey( + state as unknown as Parameters[0], + nextSessionKey, + true, + ); + void loadChatHistory(state as unknown as ChatState); + void refreshChatAvatar(state as unknown as Parameters[0]); +} + function renderAgentLabel(agent: GatewayAgentRow): string { const emoji = agent.identity?.emoji; const name = agent.identity?.name || agent.name || agent.id; return emoji ? `${emoji} ${name}` : name; } +function renderTeamLabel(team: TeamSummary): string { + const name = team.name || team.id; + return `\u{1F465} ${name}`; +} + function renderAgentPicker(state: AppViewState) { const agents = state.agentsList?.agents; - if (!agents || agents.length <= 1) { + const teams = state.teamsList?.teams; + const hasMultipleAgents = agents && agents.length > 1; + const hasTeams = teams && teams.length > 0; + if (!hasMultipleAgents && !hasTeams) { return nothing; } - const currentAgentId = resolveCurrentAgentId(state.sessionKey, state.agentsList); + const currentValue = resolveCurrentPickerValue(state); return html` | diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 5624117dcdff..4e8a91db79d3 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -61,6 +61,7 @@ export type AppViewState = { chatStream: string | null; chatStreamStartedAt: number | null; chatRunId: string | null; + chatTeamId: string | null; compactionStatus: CompactionStatus | null; activityFeed: ActivityEntry[]; chatAvatarUrl: string | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 7d7e0a95fb79..a0e58b2be039 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -133,6 +133,7 @@ export class OpenClawApp extends LitElement { @state() chatStream: string | null = null; @state() chatStreamStartedAt: number | null = null; @state() chatRunId: string | null = null; + @state() chatTeamId: string | null = null; @state() compactionStatus: CompactionStatus | null = null; @state() activityFeed: ActivityEntry[] = []; @state() chatAvatarUrl: string | null = null; diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index b99c38cae1d9..59f379e8f327 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -8,6 +8,7 @@ function createState(overrides: Partial = {}): ChatState { chatMessage: "", chatMessages: [], chatRunId: null, + chatTeamId: null, chatSending: false, chatStream: null, chatStreamStartedAt: null, diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 127e03dd4d2f..88cd6cd784bb 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -14,6 +14,7 @@ export type ChatState = { chatMessage: string; chatAttachments: ChatAttachment[]; chatRunId: string | null; + chatTeamId: string | null; chatStream: string | null; chatStreamStartedAt: number | null; lastError: string | null; @@ -123,13 +124,23 @@ export async function sendChatMessage( : undefined; try { - await state.client.request("chat.send", { - sessionKey: state.sessionKey, - message: msg, - deliver: false, - idempotencyKey: runId, - attachments: apiAttachments, - }); + if (state.chatTeamId) { + await state.client.request("teams.run", { + teamId: state.chatTeamId, + message: msg, + sessionKey: state.sessionKey, + idempotencyKey: runId, + deliver: false, + }); + } else { + await state.client.request("chat.send", { + sessionKey: state.sessionKey, + message: msg, + deliver: false, + idempotencyKey: runId, + attachments: apiAttachments, + }); + } return runId; } catch (err) { const error = String(err); From 521ae072e7755b7ded34f65ef576a9ba5a04e13a Mon Sep 17 00:00:00 2001 From: Achuth Karakkat Date: Thu, 19 Feb 2026 08:34:33 +0530 Subject: [PATCH 5/6] feat: auto-allow team leads to access member sessions (status, send, history) --- src/agents/tools/session-status-tool.ts | 9 ++--- src/agents/tools/sessions-helpers.ts | 24 +++++++++++++ src/agents/tools/sessions-history-tool.ts | 22 +++++------- src/agents/tools/sessions-send-tool.ts | 41 ++++++++--------------- 4 files changed, 51 insertions(+), 45 deletions(-) diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 2eb20cbbecd0..26c34ff9c37c 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -277,15 +277,16 @@ export function createSessionStatusTool(opts?: { if (targetAgentId === requesterAgentId) { return; } - // Gate cross-agent access behind tools.agentToAgent settings. + // isAllowed handles same-agent, team-lead-to-member, and explicit a2a config. + if (a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) { + return; + } if (!a2aPolicy.enabled) { throw new Error( "Agent-to-agent status is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.", ); } - if (!a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) { - throw new Error("Agent-to-agent session status denied by tools.agentToAgent.allow."); - } + throw new Error("Agent-to-agent session status denied by tools.agentToAgent.allow."); }; if (requestedKeyRaw.startsWith("agent:")) { diff --git a/src/agents/tools/sessions-helpers.ts b/src/agents/tools/sessions-helpers.ts index 64680cc7f66a..cc7ce1ab3db7 100644 --- a/src/agents/tools/sessions-helpers.ts +++ b/src/agents/tools/sessions-helpers.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; import { isAcpSessionKey, normalizeMainKey } from "../../routing/session-key.js"; import { sanitizeUserFacingText } from "../pi-embedded-helpers.js"; import { @@ -79,6 +80,24 @@ export function createAgentToAgentPolicy(cfg: OpenClawConfig): AgentToAgentPolic const routingA2A = cfg.tools?.agentToAgent; const enabled = routingA2A?.enabled === true; const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : []; + + // Build a set of team-lead -> members relationships so leads can always + // access their own team members without requiring the global a2a toggle. + const teamLeadMembers = new Map>(); + if (Array.isArray(cfg.teams)) { + for (const team of cfg.teams) { + const leadId = normalizeAgentId(team.lead); + let members = teamLeadMembers.get(leadId); + if (!members) { + members = new Set(); + teamLeadMembers.set(leadId, members); + } + for (const memberId of team.members) { + members.add(normalizeAgentId(memberId)); + } + } + } + const matchesAllow = (agentId: string) => { if (allowPatterns.length === 0) { return true; @@ -103,6 +122,11 @@ export function createAgentToAgentPolicy(cfg: OpenClawConfig): AgentToAgentPolic if (requesterAgentId === targetAgentId) { return true; } + // Team leads can always access their members' sessions. + const members = teamLeadMembers.get(requesterAgentId); + if (members?.has(targetAgentId)) { + return true; + } if (!enabled) { return false; } diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index 9038e9b902a7..b3faf3bc359b 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -232,20 +232,14 @@ export function createSessionsHistoryTool(opts?: { const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey); const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey); const isCrossAgent = requesterAgentId !== targetAgentId; - if (isCrossAgent) { - if (!a2aPolicy.enabled) { - return jsonResult({ - status: "forbidden", - error: - "Agent-to-agent history is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.", - }); - } - if (!a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) { - return jsonResult({ - status: "forbidden", - error: "Agent-to-agent history denied by tools.agentToAgent.allow.", - }); - } + if (isCrossAgent && !a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) { + const error = !a2aPolicy.enabled + ? "Agent-to-agent history is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access." + : "Agent-to-agent history denied by tools.agentToAgent.allow."; + return jsonResult({ + status: "forbidden", + error, + }); } const limit = diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index e871847fb656..8aa0e8b274eb 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -110,19 +110,14 @@ export function createSessionsSendTool(opts?: { } if (requesterAgentId && requestedAgentId && requestedAgentId !== requesterAgentId) { - if (!a2aPolicy.enabled) { - return jsonResult({ - runId: crypto.randomUUID(), - status: "forbidden", - error: - "Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.", - }); - } if (!a2aPolicy.isAllowed(requesterAgentId, requestedAgentId)) { + const error = !a2aPolicy.enabled + ? "Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends." + : "Agent-to-agent messaging denied by tools.agentToAgent.allow."; return jsonResult({ runId: crypto.randomUUID(), status: "forbidden", - error: "Agent-to-agent messaging denied by tools.agentToAgent.allow.", + error, }); } } @@ -227,24 +222,16 @@ export function createSessionsSendTool(opts?: { const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey); const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey); const isCrossAgent = requesterAgentId !== targetAgentId; - if (isCrossAgent) { - if (!a2aPolicy.enabled) { - return jsonResult({ - runId: crypto.randomUUID(), - status: "forbidden", - error: - "Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.", - sessionKey: displayKey, - }); - } - if (!a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) { - return jsonResult({ - runId: crypto.randomUUID(), - status: "forbidden", - error: "Agent-to-agent messaging denied by tools.agentToAgent.allow.", - sessionKey: displayKey, - }); - } + if (isCrossAgent && !a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) { + const error = !a2aPolicy.enabled + ? "Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends." + : "Agent-to-agent messaging denied by tools.agentToAgent.allow."; + return jsonResult({ + runId: crypto.randomUUID(), + status: "forbidden", + error, + sessionKey: displayKey, + }); } const agentMessageContext = buildAgentToAgentMessageContext({ From 93dcef7bd36010edfd290f7d08c71468072ff50f Mon Sep 17 00:00:00 2001 From: Achuth Karakkat Date: Thu, 19 Feb 2026 14:26:40 +0530 Subject: [PATCH 6/6] feat: add synchronous wait mode to sessions_spawn for team leads --- src/agents/subagent-registry.ts | 17 ++++++ src/agents/tools/sessions-spawn-tool.ts | 77 ++++++++++++++++++++++--- src/teams/resolve.ts | 8 ++- 3 files changed, 93 insertions(+), 9 deletions(-) diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index f335c2df6bc3..dc027a5e1a90 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -308,6 +308,23 @@ function beginSubagentCleanup(runId: string) { return true; } +export function markSubagentRunCleanupHandled(runId: string): boolean { + const key = runId.trim(); + if (!key) { + return false; + } + const entry = subagentRuns.get(key); + if (!entry) { + return false; + } + if (entry.cleanupHandled) { + return true; + } + entry.cleanupHandled = true; + persistSubagentRuns(); + return true; +} + export function markSubagentRunForSteerRestart(runId: string) { const key = runId.trim(); if (!key) { diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index f57ee4e2db21..562583264078 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -14,7 +14,12 @@ import { resolveDefaultModelForAgent } from "../model-selection.js"; import { optionalStringEnum } from "../schema/typebox.js"; import { buildSubagentSystemPrompt } from "../subagent-announce.js"; import { getSubagentDepthFromSessionStore } from "../subagent-depth.js"; -import { countActiveRunsForSession, registerSubagentRun } from "../subagent-registry.js"; +import { + countActiveRunsForSession, + markSubagentRunCleanupHandled, + registerSubagentRun, +} from "../subagent-registry.js"; +import { readLatestAssistantReply } from "./agent-step.js"; import { jsonResult, readStringParam } from "./common.js"; import { resolveDisplaySessionKey, @@ -30,6 +35,7 @@ const SessionsSpawnToolSchema = Type.Object({ thinking: Type.Optional(Type.String()), runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), cleanup: optionalStringEnum(["delete", "keep"] as const), + wait: Type.Optional(Type.Boolean()), }); function splitModelRef(ref?: string) { @@ -90,6 +96,7 @@ export function createSessionsSpawnTool(opts?: { const thinkingOverrideRaw = readStringParam(params, "thinking"); const cleanup = params.cleanup === "keep" || params.cleanup === "delete" ? params.cleanup : "keep"; + const waitForResult = params.wait === true; const requesterOrigin = normalizeDeliveryContext({ channel: opts?.agentChannel, accountId: opts?.agentAccountId, @@ -328,13 +335,67 @@ export function createSessionsSpawnTool(opts?: { runTimeoutSeconds, }); - return jsonResult({ - status: "accepted", - childSessionKey, - runId: childRunId, - modelApplied: resolvedModel ? modelApplied : undefined, - warning: modelWarning, - }); + if (!waitForResult) { + return jsonResult({ + status: "accepted", + childSessionKey, + runId: childRunId, + modelApplied: resolvedModel ? modelApplied : undefined, + warning: modelWarning, + }); + } + + // Synchronous spawn: block until the sub-agent finishes and return the result inline. + const waitTimeoutMs = runTimeoutSeconds > 0 ? runTimeoutSeconds * 1000 : 120_000; + try { + const wait = await callGateway<{ + status?: string; + startedAt?: number; + endedAt?: number; + error?: string; + }>({ + method: "agent.wait", + params: { runId: childRunId, timeoutMs: waitTimeoutMs }, + timeoutMs: waitTimeoutMs + 10_000, + }); + + if (wait?.status === "timeout") { + // Let the announce flow handle it normally (don't suppress). + return jsonResult({ + status: "timeout", + childSessionKey, + runId: childRunId, + }); + } + + // Suppress the announce flow — the lead already has the result inline. + markSubagentRunCleanupHandled(childRunId); + + const resultText = await readLatestAssistantReply({ sessionKey: childSessionKey }); + const runtimeMs = + typeof wait?.startedAt === "number" && typeof wait?.endedAt === "number" + ? Math.max(0, wait.endedAt - wait.startedAt) + : undefined; + + return jsonResult({ + status: "completed", + result: resultText ?? "(no output)", + childSessionKey, + runId: childRunId, + runtimeMs, + modelApplied: resolvedModel ? modelApplied : undefined, + warning: modelWarning, + }); + } catch { + // On RPC failure, fall back to accepted — announce will still fire. + return jsonResult({ + status: "accepted", + childSessionKey, + runId: childRunId, + modelApplied: resolvedModel ? modelApplied : undefined, + warning: modelWarning, + }); + } }, }; } diff --git a/src/teams/resolve.ts b/src/teams/resolve.ts index 64a4fc384643..801850381bc0 100644 --- a/src/teams/resolve.ts +++ b/src/teams/resolve.ts @@ -100,7 +100,13 @@ export function buildTeamContextPrompt(opts: { cfg: OpenClawConfig; team: TeamCo } lines.push(""); - lines.push("Route tasks to the appropriate team member using sessions_spawn."); + lines.push("Use sessions_spawn to delegate tasks to team members:"); + lines.push( + "- With `wait: true`: blocks until the member completes and returns the result inline. Use when you need the result before deciding next steps.", + ); + lines.push( + "- Without `wait` (default): fire-and-forget. The member's result is announced back in a follow-up turn. Use for parallel fan-out of independent tasks.", + ); return lines.join("\n"); }