diff --git a/docs/content/docs/(configuration)/config.mdx b/docs/content/docs/(configuration)/config.mdx index 52f93c622..e85ab5a7d 100644 --- a/docs/content/docs/(configuration)/config.mdx +++ b/docs/content/docs/(configuration)/config.mdx @@ -284,7 +284,7 @@ A file watcher (via the `notify` crate) monitors: - `~/.spacebot/config.toml` - `~/.spacebot/skills/` (instance-level skills) -- Each agent's `workspace/` (identity files: SOUL.md, IDENTITY.md, USER.md) +- Each agent's root directory (identity files: SOUL.md, IDENTITY.md, ROLE.md) - Each agent's `workspace/skills/` (workspace-level skills) On file change, Spacebot re-reads the changed files and atomically swaps the new values into the live `RuntimeConfig` using `arc-swap`. All consumers (channels, branches, workers, compactors, cron jobs) read from `RuntimeConfig` on every use, so they pick up changes immediately. @@ -315,10 +315,10 @@ System prompts (channel, branch, worker, compactor, cortex, etc.) are Jinja2 tem │ └── SKILL.md └── agents/ └── main/ - ├── workspace/ # agent workspace - │ ├── SOUL.md # personality (hot-reloaded) - │ ├── IDENTITY.md # name and nature (hot-reloaded) - │ ├── USER.md # info about the human (hot-reloaded) + ├── SOUL.md # personality (hot-reloaded) + ├── IDENTITY.md # name and nature (hot-reloaded) + ├── ROLE.md # responsibilities, scope (hot-reloaded) + ├── workspace/ # sandbox boundary for worker file tools │ ├── skills/ # workspace-level skills (hot-reloaded) │ └── ingest/ # drop files here for memory ingestion ├── data/ diff --git a/docs/content/docs/(configuration)/permissions.mdx b/docs/content/docs/(configuration)/permissions.mdx index 4c6113cc1..5c7caf7fc 100644 --- a/docs/content/docs/(configuration)/permissions.mdx +++ b/docs/content/docs/(configuration)/permissions.mdx @@ -41,11 +41,13 @@ Values: Workspace confinement means the agent can access: ``` -~/.spacebot/agents/{agent_id}/workspace/ # identity files, prompt overrides +~/.spacebot/agents/{agent_id}/workspace/ # working files, prompt overrides (sandbox boundary) ~/.spacebot/agents/{agent_id}/data/ # databases (read-only for file tool, managed by Spacebot) ~/.spacebot/agents/{agent_id}/archives/ # compaction archives ``` +Identity files (`SOUL.md`, `IDENTITY.md`, `ROLE.md`) live in the agent root (`~/.spacebot/agents/{agent_id}/`), outside the workspace, and are not accessible to worker file tools. + But NOT: ``` diff --git a/docs/content/docs/(configuration)/sandbox.mdx b/docs/content/docs/(configuration)/sandbox.mdx index 4c801e7a0..caf003187 100644 --- a/docs/content/docs/(configuration)/sandbox.mdx +++ b/docs/content/docs/(configuration)/sandbox.mdx @@ -86,7 +86,7 @@ When the sandbox is enabled, the subprocess sees: | `/dev` | Read-write | Standard device nodes | | Agent data directory | **No access** | Masked/denied to protect databases and config | -The data directory protection is important: even if the data directory overlaps with workspace-related paths, it's explicitly blocked. Workers can't read or modify databases, config files, or identity files at the kernel level. +The data directory protection is important: even if the data directory overlaps with workspace-related paths, it's explicitly blocked. Workers can't read or modify databases or config files at the kernel level. Identity files (`SOUL.md`, `IDENTITY.md`, `ROLE.md`) live in the agent root directory, outside the workspace entirely, so they are naturally inaccessible to workers without needing kernel-level protection. ## Environment Sanitization diff --git a/docs/content/docs/(configuration)/secrets.mdx b/docs/content/docs/(configuration)/secrets.mdx index 9c954bdd5..0ae0c7d79 100644 --- a/docs/content/docs/(configuration)/secrets.mdx +++ b/docs/content/docs/(configuration)/secrets.mdx @@ -72,6 +72,16 @@ config.toml value (secret: / env: / literal) If `anthropic_key` is set to `"secret:ANTHROPIC_API_KEY"` and the secret store has that key, it resolves to the stored value. If the store doesn't have it, the key is treated as missing and the implicit env fallback is tried. +## Integration Setup + +Tool secrets are the authentication layer for external integrations. The typical setup flow: + +1. **Create a credential** from the external service (e.g. a GitHub personal access token) +2. **Store it as a tool secret** using the name the CLI tool expects (e.g. `GH_TOKEN` for `gh`) +3. **Install a matching skill** from [skills.sh](https://skills.sh) that teaches workers how to use the tool + +See [Skills — Setting Up an Integration](/docs/features/skills#setting-up-an-integration) for a complete walkthrough. + ## How Secrets Reach Subprocesses Tool-category secrets are injected into worker subprocesses as environment variables. The flow: diff --git a/docs/content/docs/(core)/agents.mdx b/docs/content/docs/(core)/agents.mdx index 30b73746c..548769596 100644 --- a/docs/content/docs/(core)/agents.mdx +++ b/docs/content/docs/(core)/agents.mdx @@ -13,7 +13,8 @@ Agents communicate through an explicit communication graph — directed links th An agent is a self-contained unit. It has: -- **A workspace** — directory containing identity files (SOUL.md, IDENTITY.md, USER.md, ROLE.md) and optional prompt overrides +- **Identity files** — `SOUL.md`, `IDENTITY.md`, and `ROLE.md` in the agent root directory (`~/.spacebot/agents/{id}/`) +- **A workspace** — sandboxed directory (`~/.spacebot/agents/{id}/workspace/`) for working files, ingest, and prompt overrides; this is the boundary for worker file tools - **Its own databases** — SQLite, LanceDB, and redb, completely isolated from other agents - **Its own cortex** — monitoring its own processes and memory graph - **Its own conversations** — channels, branches, workers scoped to this agent @@ -224,11 +225,10 @@ Returns agents, humans, links, and groups for rendering. │ ├── agents/ │ ├── research/ -│ │ ├── workspace/ -│ │ │ ├── SOUL.md # personality, values, boundaries -│ │ │ ├── IDENTITY.md # name, nature, vibe -│ │ │ ├── USER.md # info about the human -│ │ │ └── ROLE.md # responsibilities, scope, escalation rules +│ │ ├── SOUL.md # personality, values, boundaries +│ │ ├── IDENTITY.md # name, nature, vibe +│ │ ├── ROLE.md # responsibilities, scope, escalation rules +│ │ ├── workspace/ # sandbox boundary for worker file tools │ │ ├── data/ │ │ │ ├── spacebot.db # SQLite (memories, conversations, cron jobs) │ │ │ ├── lancedb/ # LanceDB (embeddings, FTS) diff --git a/docs/content/docs/(core)/architecture.mdx b/docs/content/docs/(core)/architecture.mdx index 89dd11c79..ac5171418 100644 --- a/docs/content/docs/(core)/architecture.mdx +++ b/docs/content/docs/(core)/architecture.mdx @@ -412,7 +412,7 @@ src/ **The compactor is not an LLM.** It's a programmatic monitor that watches a number and spawns workers. The LLM work happens in the workers it spawns. -**Prompts are files.** System prompts live in `prompts/` as Jinja2 templates, not as string constants in Rust code. Identity files (SOUL.md, IDENTITY.md, USER.md, ROLE.md) are loaded from the agent's workspace directory. +**Prompts are files.** System prompts live in `prompts/` as Jinja2 templates, not as string constants in Rust code. Identity files (SOUL.md, IDENTITY.md, ROLE.md) live in the agent root directory — outside the workspace sandbox — so worker file tools cannot access them. **Three databases, three purposes.** SQLite for relational queries, LanceDB for vector search, redb for key-value config. Each doing what it's best at. diff --git a/docs/content/docs/(core)/cortex.mdx b/docs/content/docs/(core)/cortex.mdx index 3355751ae..41aef7c5f 100644 --- a/docs/content/docs/(core)/cortex.mdx +++ b/docs/content/docs/(core)/cortex.mdx @@ -49,8 +49,8 @@ The bulletin is injected into the system prompt between identity context and the ## Identity [from IDENTITY.md] -## User -[from USER.md] +## Role +[from ROLE.md] ## Memory Context ← this is the bulletin [A concise briefing synthesized from eight memory dimensions: diff --git a/docs/content/docs/(core)/memory.mdx b/docs/content/docs/(core)/memory.mdx index 1f8c92378..6577bba84 100644 --- a/docs/content/docs/(core)/memory.mdx +++ b/docs/content/docs/(core)/memory.mdx @@ -162,9 +162,9 @@ Not everything is a graph memory. Some context is stable, foundational, and user - **SOUL.md** -- core values, personality, tone - **IDENTITY.md** -- agent name, nature -- **USER.md** -- user context +- **ROLE.md** -- responsibilities, scope, escalation rules -These are loaded into channel system prompts every time. They're files on disk, not database rows, because they change rarely and users should be able to edit them in a text editor. +These are loaded into channel system prompts every time. They're files in the agent root directory (not the workspace), not database rows, because they change rarely and users should be able to edit them in a text editor. What's gone: diff --git a/docs/content/docs/(core)/prompts.mdx b/docs/content/docs/(core)/prompts.mdx index 219c35263..8581be1a8 100644 --- a/docs/content/docs/(core)/prompts.mdx +++ b/docs/content/docs/(core)/prompts.mdx @@ -242,7 +242,7 @@ let marker = engine.render_system_truncation(remove_count)?; ## No User Overrides -Unlike identity files (SOUL.md, IDENTITY.md, USER.md), system prompts cannot be modified by users. This ensures: +Unlike identity files (SOUL.md, IDENTITY.md, ROLE.md), system prompts cannot be modified by users. This ensures: - Updates ship reliably (no local modifications to overwrite) - Consistent behavior across deployments diff --git a/docs/content/docs/(deployment)/roadmap.mdx b/docs/content/docs/(deployment)/roadmap.mdx index 09673870f..61afccb9e 100644 --- a/docs/content/docs/(deployment)/roadmap.mdx +++ b/docs/content/docs/(deployment)/roadmap.mdx @@ -22,7 +22,7 @@ The full message-in → LLM → response-out pipeline is wired end-to-end across - **Model routing** — `RoutingConfig` with process-type defaults, task overrides, fallback chains - **Memory** — full stack: types, SQLite store (CRUD + graph), LanceDB (embeddings + vector + FTS), fastembed, hybrid search (RRF fusion). `memory_type` filter wired end-to-end through SearchConfig. `total_cmp` for safe sorting. - **Memory maintenance** — decay + prune implemented -- **Identity** — `Identity` struct loads SOUL.md/IDENTITY.md/USER.md, `Prompts` with fallback chain +- **Identity** — `Identity` struct loads SOUL.md/IDENTITY.md/ROLE.md from agent root, `Prompts` with fallback chain - **Agent loops** — all three process types run real Rig loops: - **Channel** — per-turn tool registration, status injection, `max_turns(5)` - **Branch** — history fork, `max_turns(10)`, memory tools, result injection diff --git a/docs/content/docs/(features)/skills.mdx b/docs/content/docs/(features)/skills.mdx index 625aabc5d..12e45b789 100644 --- a/docs/content/docs/(features)/skills.mdx +++ b/docs/content/docs/(features)/skills.mdx @@ -20,6 +20,41 @@ Spacebot's skill system is fully compatible with [skills.sh](https://skills.sh) - **Worker injection** — skills are injected into worker system prompts, not channels - **Hot-reloadable** — file watcher picks up skill changes without restart +## Setting Up an Integration + +Most external tool integrations follow the same pattern: **add a credential, install a skill**. Here's how to set up GitHub as an example — the same pattern applies to AWS, npm, Docker, and any other CLI tool. + +### Example: GitHub Integration + +1. **Create a GitHub personal access token** at [github.com/settings/tokens](https://github.com/settings/tokens) with the scopes you need (e.g. `repo`, `read:org`). + +2. **Store it as a tool secret** so workers can authenticate: + + ```bash + spacebot secrets set GH_TOKEN + # Paste your token when prompted + ``` + + Or use the dashboard **Secrets** panel to add `GH_TOKEN` with your token value. + +3. **Install a GitHub skill** that teaches workers how to use the `gh` CLI: + + ```bash + spacebot skill add anthropics/skills/github + ``` + + Or browse the **Skills** tab in the dashboard and search for "github". + +4. **Done.** Workers now have `GH_TOKEN` as an environment variable and can read the GitHub skill for instructions on creating PRs, managing issues, and more. + +### Why This Works + +- **Tool secrets** with names like `GH_TOKEN`, `NPM_TOKEN`, `AWS_ACCESS_KEY_ID` are automatically categorized as "tool" secrets and injected into every worker subprocess as environment variables. +- **Skills** provide the procedural knowledge — they tell workers *how* to use the CLI tools that those credentials unlock. +- Workers see the secret names in their system prompt and can call `read_skill` to load full instructions on demand. + +This pattern works for any external tool that authenticates via environment variables. See [Secret Store](/docs/configuration/secrets) for details on credential storage and auto-categorization. + ## Installation ### Via CLI diff --git a/docs/content/docs/(features)/tools.mdx b/docs/content/docs/(features)/tools.mdx index 05fa01679..5878194e6 100644 --- a/docs/content/docs/(features)/tools.mdx +++ b/docs/content/docs/(features)/tools.mdx @@ -205,7 +205,7 @@ Shell and exec commands run inside an OS-level sandbox (bubblewrap on Linux, san Worker subprocesses also start with a clean environment -- they never inherit the parent's environment variables. System secrets (LLM API keys, messaging tokens) are never visible to workers regardless of sandbox mode. See [Sandbox](/docs/sandbox) for full details. -The `file` tool independently validates paths against the workspace boundary and rejects writes to identity files (`SOUL.md`, `IDENTITY.md`, `USER.md`). The `exec` tool blocks dangerous environment variables (`LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`, etc.) that enable library injection regardless of sandbox state. +The `file` tool independently validates all paths against the workspace boundary. Identity files (`SOUL.md`, `IDENTITY.md`, `ROLE.md`) live in the agent root directory (`~/.spacebot/agents/{id}/`), outside the workspace, so they are naturally inaccessible to worker file tools. The `exec` tool blocks dangerous environment variables (`LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`, etc.) that enable library injection regardless of sandbox state. Leak detection (via `SpacebotHook`) scans all tool output for secret patterns (API keys, tokens, PEM keys) and terminates the process if a leak is found. This includes base64-encoded, URL-encoded, and hex-encoded variants. diff --git a/docs/content/docs/(features)/workers.mdx b/docs/content/docs/(features)/workers.mdx index 84e9d3a5c..70499d48d 100644 --- a/docs/content/docs/(features)/workers.mdx +++ b/docs/content/docs/(features)/workers.mdx @@ -156,7 +156,7 @@ The agent's data directory (databases, config files) is explicitly re-mounted re Worker subprocesses also start with a **clean environment**. Workers only receive `PATH` (with `tools/bin` prepended), safe variables (`HOME`, `USER`, `LANG`, `TERM`, `TMPDIR`), tool-category secrets from the [secret store](/docs/secrets), and any explicitly configured `passthrough_env` entries. `HOME` is mode-dependent: workspace path when sandboxed, parent `HOME` in passthrough mode. System secrets like LLM API keys are hidden by default unless explicitly forwarded via `passthrough_env`. Environment sanitization applies regardless of whether the sandbox is enabled or disabled. -The `file` tool validates paths against the workspace boundary and rejects writes to identity/memory paths (for example `SOUL.md`, `IDENTITY.md`, `USER.md`) with an explicit error directing the LLM to the appropriate tool. The `exec` tool blocks dangerous environment variables (`LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`, etc.) that enable library injection. +The `file` tool validates all paths against the workspace boundary. Identity files (`SOUL.md`, `IDENTITY.md`, `ROLE.md`) live in the agent root directory (`~/.spacebot/agents/{id}/`), outside the workspace, so they are naturally inaccessible to worker file tools — no special-case rejection is needed. The `exec` tool blocks dangerous environment variables (`LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`, etc.) that enable library injection. See [Sandbox](/docs/sandbox) for full details on containment, environment sanitization, leak detection, and durable binaries. diff --git a/docs/content/docs/(getting-started)/docker.mdx b/docs/content/docs/(getting-started)/docker.mdx index ece45fbae..86a085fb5 100644 --- a/docs/content/docs/(getting-started)/docker.mdx +++ b/docs/content/docs/(getting-started)/docker.mdx @@ -48,7 +48,10 @@ All persistent data lives at `/data` inside the container. Mount a volume here. ├── embedding_cache/ # FastEmbed model cache (~100MB, downloaded on first run) ├── agents/ │ └── main/ -│ ├── workspace/ # identity files (SOUL.md, IDENTITY.md, USER.md) +│ ├── SOUL.md # agent personality and voice +│ ├── IDENTITY.md # agent purpose and scope +│ ├── ROLE.md # agent responsibilities +│ ├── workspace/ # working directory (sandbox boundary) │ ├── data/ # SQLite, LanceDB, redb databases │ └── archives/ # compaction transcripts └── logs/ # log files (daily rotation) diff --git a/docs/content/docs/(getting-started)/quickstart.mdx b/docs/content/docs/(getting-started)/quickstart.mdx index 934dd9519..c2755ce29 100644 --- a/docs/content/docs/(getting-started)/quickstart.mdx +++ b/docs/content/docs/(getting-started)/quickstart.mdx @@ -132,7 +132,8 @@ On first launch, Spacebot automatically creates: - `~/.spacebot/` — instance directory - `~/.spacebot/agents/main/data/` — SQLite, LanceDB, and redb databases -- `~/.spacebot/agents/main/workspace/` — identity files and ingest directory +- `~/.spacebot/agents/main/` — identity files (`SOUL.md`, `IDENTITY.md`, `ROLE.md`) +- `~/.spacebot/agents/main/workspace/` — working files and ingest directory (sandbox boundary for worker file tools) ## Daemon management @@ -147,15 +148,15 @@ Logs go to `~/.spacebot/agents/{id}/data/logs/` in daemon mode, or stderr in for ## Identity files -Each agent has three optional markdown files in its workspace (`~/.spacebot/agents/{id}/workspace/`): +Each agent has optional identity files in its root directory (`~/.spacebot/agents/{id}/`): | File | Purpose | | ------------- | ---------------------------------------- | | `SOUL.md` | Personality, values, communication style | | `IDENTITY.md` | Name, nature, purpose | -| `USER.md` | Info about the human the agent talks to | +| `ROLE.md` | Responsibilities, scope, escalation rules | -Template files are created on first run. Edit them to shape the agent's personality. Changes are hot-reloaded (no restart needed). +Template files are created on first run. Edit them to shape the agent's personality. Changes are hot-reloaded (no restart needed). These files live outside the workspace so they are not accessible to worker file tools. ## Development setup diff --git a/docs/design-docs/agent-factory.md b/docs/design-docs/agent-factory.md new file mode 100644 index 000000000..d28f5b4eb --- /dev/null +++ b/docs/design-docs/agent-factory.md @@ -0,0 +1,502 @@ +# Agent Factory + +A natural-language-driven system for creating, configuring, and refining agents. The main agent (or cortex chat) guides the user through agent creation using conversational questions, preset soul archetypes, and the main agent's existing memories — producing fully configured agents with rich identity files, appropriate model routing, org links, and platform bindings. + +## Problem + +Creating a new agent today requires: +1. Give it a name and ID (the only thing the UI captures) +2. Manually write SOUL.md, IDENTITY.md, USER.md, ROLE.md from scratch +3. Edit config.toml to set routing, tuning, and platform bindings +4. Create links to other agents in the topology +5. Understand the architecture well enough to make good choices + +There are no presets, no guidance on writing a soul, no way to leverage existing context. Most users give up after step 1 and end up with a blank agent that doesn't know who it is. + +## Solution + +The factory is a guided creation flow where the user describes what they want in natural language. The system synthesizes a fully configured agent from three sources: + +1. **User intent** — conversational answers about what the agent should do, how it should communicate, what platforms it should be on +2. **Preset archetypes** — 8 curated soul/identity/role templates that serve as starting material for synthesis (never used directly) +3. **Main agent's memories** — searched at creation time for company context, domain knowledge, preferences, organizational structure + +The factory is not a form. It's a conversation with the agent that happens to produce another agent. + +--- + +## Phase 0: Deprecate USER.md — Humans Own Their Context + +### Motivation + +USER.md is a per-agent file that describes "the human this agent interacts with." This made sense for single-agent setups, but breaks down in multi-agent orgs: + +- Every agent needs its own copy of USER.md, manually maintained +- There's no way to have multiple humans interact with multiple agents without duplicating context everywhere +- The org graph already has `[[humans]]` nodes with `display_name`, `role`, and `bio`, but this data is completely disconnected from USER.md +- `build_org_context()` doesn't even use the human's `display_name` — it falls back to the raw id (e.g., "admin") + +The fix: human context belongs to the human node in the org graph. When a human is linked to an agent, the agent inherits that human's description in its system prompt. USER.md becomes unnecessary. + +### Changes + +#### 1. Add `description` field to `HumanDef` + +The existing `bio` field is a short 2-3 sentence blurb for the topology UI. The new `description` field is the rich, long-form equivalent of what USER.md was — paragraphs of context about the person (preferences, background, communication style, timezone, etc.). + +```rust +// config.rs +pub struct HumanDef { + pub id: String, + pub display_name: Option, + pub role: Option, + pub bio: Option, + pub description: Option, // NEW — replaces USER.md +} +``` + +TOML representation: + +```toml +[[humans]] +id = "jamie" +display_name = "Jamie Pine" +role = "Founder" +bio = "Creator of Spacebot and Spacedrive." +description = """ +Jamie Pine (@jamiepine). Timezone: America/Vancouver. + +Full-stack developer, open source maintainer. Created Spacedrive (cross-platform +file manager) and Spacebot (agentic AI system). Works in Rust and TypeScript. + +Streams development on Twitch. Prefers direct, concise communication. Comfortable +with technical depth. Uses AI tools extensively in his workflow. +""" +``` + +#### 2. Propagate human descriptions into org_context + +Currently `build_org_context()` creates `LinkedAgent { name, id, is_human }` structs and the template only shows the name. This needs to: + +a. Look up the `HumanDef` for linked humans (not just negative-lookup against `agent_names`) +b. Use `display_name` for the human's name (falling back to id) +c. Pass `description` (and `role`, `bio`) through to the template + +New template data: + +```rust +// engine.rs +pub struct LinkedEntity { + pub name: String, // display_name or id + pub id: String, + pub is_human: bool, + pub role: Option, + pub description: Option, // human description, injected into prompt +} +``` + +Updated org_context.md.j2 fragment: + +```jinja2 +{% if entry.is_human -%} +- **{{ entry.name }}** (human){% if entry.role %} — {{ entry.role }}{% endif %} +{% if entry.description %} +{{ entry.description }} +{% endif %} +{% else -%} +- **{{ entry.name }}** — ... +{% endif -%} +``` + +This means: when a human is linked to an agent, the agent's system prompt includes that human's full description in the Organization section. Multiple humans linked to the same agent all appear. The agent knows who it's working with based on the org graph, not a static file. + +#### 3. Deprecate USER.md loading + +- `Identity::load()` stops loading USER.md (set `user: None` unconditionally) +- `Identity::render()` stops rendering the `## User` section +- `scaffold_identity_files()` stops creating USER.md for new agents +- The file tool's `PROTECTED_FILES` list removes USER.md +- If a USER.md file exists on disk, it is ignored (not deleted — just not loaded) + +#### 4. Startup migration for existing users + +On startup, after config is loaded but before agents are initialized: + +1. **For each agent with a non-empty USER.md in its workspace:** + - Read the content of `workspace/USER.md` + - If there is exactly one human in the config (the default "admin"), set its `description` to the USER.md content + - If there are multiple humans, log a warning: "USER.md found for agent '{id}' but multiple humans exist. Please manually assign the content to the appropriate human's description." + - Rename the file to `USER.md.deprecated` so it's preserved but no longer loaded + +2. **Auto-link the default human to the main agent:** + - If there is a human with no links to any agent, create a hierarchical link from that human to the default agent (the human is the superior — they are the owner) + - This only runs on first migration — once links exist, it doesn't touch them + +3. **Persist changes:** + - Write the updated `[[humans]]` with `description` to config.toml + - Write the new `[[links]]` entry to config.toml + +#### 5. Default human creation with auto-link + +Currently, if no `[[humans]]` entries exist, a bare "admin" human is created at config parse time. Extend this: + +- Still create the default "admin" human +- Also create a default link: `from = "admin"`, `to = "{default_agent_id}"`, `direction = "two_way"`, `kind = "hierarchical"` +- This ensures new installations start with the human connected to their main agent + +#### 6. API updates + +- `CreateHumanRequest` and `UpdateHumanRequest` gain a `description: Option` field +- `TopologyHuman` response includes `description` +- `GET /api/agents/identity` and `PUT /api/agents/identity` drop the `user` field (breaking change, but the field becomes meaningless) +- Add a deprecation note to the API: `user` field in identity endpoints is ignored + +#### 7. Dashboard UI updates + +- `HumanEditDialog` in TopologyGraph.tsx gets a new `description` textarea (larger than `bio`, with a label like "Full context — background, preferences, communication style") +- The agent config identity tab (`AgentConfig.tsx`) removes the USER.md tab +- The agent detail overview (`AgentDetail.tsx`) removes the USER.md preview tab + +#### 8. Humans lookup in build_org_context + +`AgentDeps` or the channel needs access to the humans list to look up descriptions. Options: + +a. Add `humans: Arc>>` to `AgentDeps` (already exists as `state.agent_humans` in the API layer — pass it through) +b. Build a `HashMap` at startup for O(1) lookup + +The channel's `build_org_context()` then: +- For each linked node, checks `agent_names` (is it an agent?) then `humans_map` (is it a human?) +- For humans: uses `display_name.unwrap_or(id)` for the name, passes `role` and `description` through + +--- + +## Phase 1: Preset System + +### Preset Structure + +Eight preset archetypes ship embedded in the binary. Each is a directory of markdown files: + +``` +presets/ + community-manager/ + SOUL.md + IDENTITY.md + ROLE.md + meta.toml + research-analyst/ + customer-support/ + engineering-assistant/ + content-writer/ + sales-bdr/ + executive-assistant/ + project-manager/ +``` + +#### meta.toml + +```toml +id = "community-manager" +name = "Community Manager" +description = "Engages with community members, moderates discussions, answers FAQs, and keeps the vibe positive." +icon = "users" +tags = ["discord", "slack", "community", "moderation", "support"] + +[defaults] +max_concurrent_workers = 3 +max_turns = 5 +``` + +Note: model routing is intentionally excluded from presets. Presets are provider-agnostic — model selection happens during the factory conversation when the user's available providers are known. + +#### Presets + +| Preset | Description | +|--------|-------------| +| Community Manager | Discord/Slack engagement, moderation, FAQ handling | +| Research Analyst | Deep research, document synthesis, report generation | +| Customer Support | Ticket triage, escalation, empathetic communication | +| Engineering Assistant | Code review, architecture, PR management, technical docs | +| Content Writer | Blog posts, docs, social media, consistent voice | +| Sales / BDR | Lead qualification, outreach drafting, follow-ups | +| Executive Assistant | Email drafting, meeting prep, information synthesis | +| Project Manager | Task tracking, status updates, cross-team coordination | + +#### Embedding + +Presets are compiled into the binary via `include_dir!`: + +```rust +impl PresetRegistry { + fn list() -> Vec; + fn load(id: &str) -> Option; +} + +struct Preset { + meta: PresetMeta, + soul: String, + identity: String, + role: String, +} +``` + +#### API + +``` +GET /api/factory/presets → Vec +GET /api/factory/presets/:id → Preset (full content) +``` + +### Files + +- `presets/` directory with 8 archetype subdirectories +- `src/factory/mod.rs` — module root +- `src/factory/presets.rs` — `PresetRegistry`, `Preset`, `PresetMeta`, `include_dir!` loading +- `src/api/factory.rs` — preset listing/loading endpoints + +--- + +## Phase 2: Factory Tools + +Six new LLM-callable tools: + +| Tool | Purpose | +|------|---------| +| `factory_list_presets` | Lists available archetypes with metadata | +| `factory_load_preset` | Loads a preset's full content (soul, identity, role, defaults) | +| `factory_search_context` | Searches the main agent's memories for relevant context | +| `factory_create_agent` | Creates a fully configured agent — calls create API, writes identity files, creates links | +| `factory_update_identity` | Updates identity files for any agent (refinement) | +| `factory_update_config` | Updates routing/tuning config for any agent | + +`factory_create_agent` takes a complete specification: + +```rust +struct FactoryCreateAgentArgs { + agent_id: String, + display_name: String, + role: String, + soul_content: String, + identity_content: String, + role_content: String, + routing: Option, + links: Vec, +} +``` + +Note: no `user_content` — human context now flows through org links, not identity files. + +### Files + +- `src/tools/factory_list_presets.rs` +- `src/tools/factory_load_preset.rs` +- `src/tools/factory_search_context.rs` +- `src/tools/factory_create_agent.rs` +- `src/tools/factory_update_identity.rs` +- `src/tools/factory_update_config.rs` +- `src/factory/synthesis.rs` — shared logic for identity file generation, preset merging +- `tools.rs` — `factory_tool_server()` function + +--- + +## Phase 3: Factory Prompt + +A dedicated template (`prompts/en/factory.md.j2`) that instructs the LLM on how to be an agent factory: + +1. **What you're doing** — Creating a new agent in a multi-agent system. You have tools to load presets, search memories, and create agents. +2. **How agents work** — Soul/identity/role system, what each file controls, how agents link, routing/tuning options. +3. **The creation flow** — Step-by-step: understand intent → search memories → present presets → ask about personality → ask about platforms → ask about org position → synthesize → summarize → create → offer refinement. +4. **Soul writing guide** — How to write effective souls. Voice, boundaries, judgment. Preset examples as reference. +5. **Synthesis rules** — Presets are starting material, not templates. Always customize. Inject organizational context from memory search. Match existing org communication style. + +### Files + +- `prompts/en/factory.md.j2` +- `src/prompts/engine.rs` — `render_factory_prompt()` +- `src/prompts/text.rs` — register template + +--- + +## Phase 4: Structured Message Types + +New generic message primitives for interactive chat components, reusable by any feature. + +### Schema + +Messages gain an optional `structured` field: + +```rust +struct StructuredContent { + kind: StructuredKind, +} + +enum StructuredKind { + /// Clickable options. User picks one (or types custom). + Buttons { + prompt: String, + buttons: Vec, + }, + /// Card grid for preset selection. Visual, with descriptions. + SelectCards { + prompt: String, + cards: Vec, + allow_multiple: bool, + }, + /// Step progress indicator. + Progress { + steps: Vec, + current: usize, + }, + /// Summary card with key-value pairs and action buttons. + Summary { + title: String, + fields: Vec<(String, String)>, + actions: Vec, + }, +} + +struct ButtonOption { + label: String, + value: String, // injected as user message when clicked + description: Option, + icon: Option, +} + +struct SelectCard { + id: String, + title: String, + description: String, + icon: Option, + tags: Vec, +} + +struct ProgressStep { + label: String, + status: StepStatus, // Pending | Active | Done | Error +} +``` + +### Transport + +Outbound SSE events include an optional `structured` JSON field. The frontend renders the appropriate component. When the user clicks a button or card, the frontend injects the `value` as a regular user message. No bidirectional widget protocol — the agent controls the flow through tool calls. + +### Frontend Components + +New components in `interface/src/components/structured/`: + +- `ChatButtons.tsx` — button group +- `CardSelect.tsx` — selectable card grid with icon, title, description, tags +- `ProgressSteps.tsx` — horizontal step indicator +- `SummaryCard.tsx` — key-value summary with action buttons + +Integrated into both `WebChatPanel` and `CortexChatPanel`. + +### Files (Rust) + +- `src/api/types.rs` or inline — `StructuredContent`, `StructuredKind`, serde +- Extend `ApiEvent::OutboundMessage` with optional `structured` field +- Extend `webchat.rs` and `cortex.rs` SSE payloads + +### Files (TypeScript) + +- `interface/src/api/client.ts` — extend types +- `interface/src/components/structured/ChatButtons.tsx` +- `interface/src/components/structured/CardSelect.tsx` +- `interface/src/components/structured/ProgressSteps.tsx` +- `interface/src/components/structured/SummaryCard.tsx` +- `WebChatPanel.tsx` and `CortexChatPanel.tsx` — render structured content + +--- + +## Phase 5: Factory Integration + +### Entry Points + +Both paths converge on the same backend: + +**Portal webchat (main agent):** User says "create me a support agent" in chat. The channel detects factory intent and delegates to a factory branch with factory tools + memory tools + the factory system prompt. + +**Cortex chat:** Factory tools are added to the cortex chat tool server. Same conversational flow, same backend. + +### Creation Flow + +``` +User: "I need an agent that handles customer support tickets from email" + +Agent: [branch → factory_search_context("company info", "support process")] + [branch → factory_list_presets()] + +Agent: "Found context about your company. Here are starting points:" + [structured: SelectCards — Customer Support, Community Manager, + Executive Assistant, with relevance tags] + +User: [clicks Customer Support] + +Agent: [branch → factory_load_preset("customer-support")] + "How should this agent communicate?" + [structured: Buttons — "Professional & formal", "Friendly & warm", + "Match the customer's tone", "Same as {main_agent}"] + +User: "Friendly but professional. We're a dev tools company." + +Agent: "Should it handle everything or escalate some things?" + [structured: Buttons — "Handle everything", "Escalate billing/refunds", + "Escalate complex issues", "Custom rules"] + +User: "Escalate billing, refunds, and anything legal." + +Agent: "Where does it fit in the org?" + [structured: Buttons — "Reports to main", "Peer of main", + "Independent"] + +User: [clicks "Reports to main"] + +Agent: [factory_create_agent with synthesized identity files] + [structured: Summary — ID, role, personality, escalation rules, + model, org position, with "Refine" and "Looks good" buttons] + +User: "Looks good!" + +Agent: "support is live. Route email to it from Settings > Bindings." +``` + +### Refinement + +After creation, or for existing agents: + +``` +User: "make the support agent more casual" +Agent: [reads current SOUL.md → rewrites with casual voice → factory_update_identity] + "Updated. More relaxed tone, still precise on technical stuff." +``` + +### Files + +- `src/agent/channel.rs` — factory intent detection, factory branch spawning +- `src/agent/cortex_chat.rs` — factory mode for cortex chat +- `src/factory/flow.rs` — shared orchestration logic (if needed) + +--- + +## Phase 6: Polish & Testing + +- Integration tests for the full factory flow +- Error handling / rollback if creation fails mid-flow +- Edge cases: duplicate IDs, hosted agent limits, permissions +- UI polish: animations, loading states, error display +- Documentation updates + +--- + +## Open Questions + +1. **Factory as a skill?** Could the factory prompt and tools be packaged as a built-in skill? Keeps it modular and consistent with the skills system. + +2. **Binding creation:** Should the factory also handle setting up platform bindings (e.g., "route emails from support@company.com to this agent")? + +3. **Agent cloning:** Should there be a "clone this agent" shortcut? + +4. **Preset contributions:** V2 could allow users to export agent configs as presets and share via registry (like skills.sh). + +5. **Memory seeding:** Should the factory optionally copy a subset of the main agent's memories to the new agent? + +6. **Structured content in other adapters:** Discord/Slack both support rich embeds and buttons. Map structured types to platform-native components in V2? diff --git a/docs/design-docs/channel-attachment-persistence.md b/docs/design-docs/channel-attachment-persistence.md index 845de4303..c76451082 100644 --- a/docs/design-docs/channel-attachment-persistence.md +++ b/docs/design-docs/channel-attachment-persistence.md @@ -56,11 +56,10 @@ workspace/ screenshot.png diagram.png report_2.pdf -- deduped name (second "report.pdf" received) - SOUL.md - IDENTITY.md - USER.md ``` +Note: Identity files (`SOUL.md`, `IDENTITY.md`, `ROLE.md`) live in the agent root directory, not in the workspace. + The `saved/` directory is created at startup alongside `ingest/` and `skills/`. A `saved_dir()` helper is added to `ResolvedAgentConfig`: diff --git a/docs/design-docs/multi-agent-communication-graph.md b/docs/design-docs/multi-agent-communication-graph.md index 083df1490..b0f77e59a 100644 --- a/docs/design-docs/multi-agent-communication-graph.md +++ b/docs/design-docs/multi-agent-communication-graph.md @@ -229,13 +229,13 @@ This is a peer agent. Communication is collaborative and informational. ### ROLE.md -New identity file alongside `SOUL.md`, `IDENTITY.md`, and `USER.md`. Defines what the agent is supposed to *do* — responsibilities, scope, what to handle vs what to escalate, what success looks like. +New identity file alongside `SOUL.md` and `IDENTITY.md`. Defines what the agent is supposed to *do* — responsibilities, scope, what to handle vs what to escalate, what success looks like. -`SOUL.md` is personality. `IDENTITY.md` is who the agent is. `USER.md` is context about the human. `ROLE.md` is the job: "you handle tier 1 support tickets, escalate billing issues to the finance agent, never touch production infrastructure." +`SOUL.md` is personality. `IDENTITY.md` is who the agent is. `ROLE.md` is the job: "you handle tier 1 support tickets, escalate billing issues to the finance agent, never touch production infrastructure." In single-agent setups, `ROLE.md` separates identity from operational responsibilities. In multi-agent setups, it's what differentiates agents operationally — each agent sees its position in the hierarchy via org context, and `ROLE.md` tells it what to actually do in that position. Structure vs scope. -Loaded the same way as the other identity files — from the agent's workspace directory, injected into the system prompt by `identity/files.rs`. +Loaded the same way as the other identity files — from the agent root directory (`~/.spacebot/agents/{id}/`), injected into the system prompt by `identity/files.rs`. ### Organizational Awareness diff --git a/docs/design-docs/sandbox.md b/docs/design-docs/sandbox.md index a65c34cc4..3835c0a48 100644 --- a/docs/design-docs/sandbox.md +++ b/docs/design-docs/sandbox.md @@ -63,7 +63,7 @@ Everything that exists today, what it does, and what happens to it. |------|-------|-------------|-------------| | `resolve_path()` method | 26-75 | Canonicalizes path, checks `starts_with(workspace)`, rejects symlinks | **Keep unchanged.** This is in-process I/O, not subprocess spawning. The sandbox doesn't apply here. | | `best_effort_canonicalize()` | 81-106 | Walks up to deepest existing ancestor for paths that don't fully exist yet | **Keep unchanged.** Used by `resolve_path()`. | -| Protected identity files | 203-216 | Blocks writes to `SOUL.md`, `IDENTITY.md`, `USER.md` (case-insensitive) | **Keep unchanged.** Application-level protection, not security boundary. | +| Protected identity files | 203-216 | Identity files (`SOUL.md`, `IDENTITY.md`, `ROLE.md`) now live in the agent root, outside the workspace. File tool path validation naturally blocks access since they are outside the workspace boundary. | **Simplified.** Explicit blocklist no longer needed — workspace containment handles it. | | System-internal `file_read/write/list` | 362-404 | Bypass workspace containment, used by the system | **Keep unchanged.** | ### `send_file.rs` — SendFileTool diff --git a/docs/docker.md b/docs/docker.md index d14e4769c..dc3e5b161 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -43,7 +43,10 @@ All persistent data lives at `/data` inside the container. Mount a volume here. ├── embedding_cache/ # FastEmbed model cache (~100MB, downloaded on first run) ├── agents/ │ └── main/ -│ ├── workspace/ # identity files (SOUL.md, IDENTITY.md, USER.md) +│ ├── SOUL.md # agent personality and voice +│ ├── IDENTITY.md # agent purpose and scope +│ ├── ROLE.md # agent responsibilities +│ ├── workspace/ # working directory (sandbox boundary) │ ├── data/ # SQLite, LanceDB, redb databases │ └── archives/ # compaction transcripts └── logs/ # log files (daily rotation) diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index fab089f91..253cae3b7 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -153,6 +153,14 @@ export interface WorkerTextEvent { text: string; } +export interface CortexChatMessageEvent { + type: "cortex_chat_message"; + agent_id: string; + thread_id: string; + content: string; + tool_calls?: CortexChatToolCall[]; +} + export type ApiEvent = | InboundMessageEvent | OutboundMessageEvent @@ -167,7 +175,8 @@ export type ApiEvent = | ToolStartedEvent | ToolCompletedEvent | OpenCodePartUpdatedEvent - | WorkerTextEvent; + | WorkerTextEvent + | CortexChatMessageEvent; async function fetchJson(path: string): Promise { const response = await fetch(`${API_BASE}${path}`); @@ -299,6 +308,8 @@ export interface AgentInfo { id: string; display_name?: string; role?: string; + gradient_start?: string; + gradient_end?: string; workspace: string; context_window: number; max_turns: number; @@ -529,6 +540,14 @@ export interface CortexEventsParams { // -- Cortex Chat -- +export interface CortexChatToolCall { + id: string; + tool: string; + args: string; + result: string | null; + status: "running" | "completed" | "error"; +} + export interface CortexChatMessage { id: string; thread_id: string; @@ -536,6 +555,7 @@ export interface CortexChatMessage { content: string; channel_context: string | null; created_at: string; + tool_calls?: CortexChatToolCall[]; } export interface CortexChatMessagesResponse { @@ -543,22 +563,56 @@ export interface CortexChatMessagesResponse { thread_id: string; } +export interface CortexChatThread { + thread_id: string; + preview: string; + message_count: number; + first_message_at: string; + last_message_at: string; +} + +export interface CortexChatThreadsResponse { + threads: CortexChatThread[]; +} + export type CortexChatSSEEvent = | { type: "thinking" } - | { type: "done"; full_text: string } + | { type: "tool_started"; tool: string; call_id: string; args: string } + | { type: "tool_completed"; tool: string; call_id: string; args: string; result: string; result_preview: string } + | { type: "done"; full_text: string; tool_calls: CortexChatToolCall[] } | { type: "error"; message: string }; +// -- Factory Presets -- + +export interface PresetDefaults { + max_concurrent_workers: number | null; + max_turns: number | null; +} + +export interface PresetMeta { + id: string; + name: string; + description: string; + icon: string; + tags: string[]; + defaults: PresetDefaults; +} + +export interface PresetsResponse { + presets: PresetMeta[]; +} + export interface IdentityFiles { soul: string | null; identity: string | null; - user: string | null; + role: string | null; } export interface IdentityUpdateRequest { agent_id: string; soul?: string | null; identity?: string | null; - user?: string | null; + role?: string | null; } // -- Agent Config Types -- @@ -1317,6 +1371,11 @@ export interface TopologyHuman { display_name?: string; role?: string; bio?: string; + description?: string; + discord_id?: string; + telegram_id?: string; + slack_id?: string; + email?: string; } export interface TopologyResponse { @@ -1331,12 +1390,22 @@ export interface CreateHumanRequest { display_name?: string; role?: string; bio?: string; + description?: string; + discord_id?: string; + telegram_id?: string; + slack_id?: string; + email?: string; } export interface UpdateHumanRequest { display_name?: string; role?: string; bio?: string; + description?: string; + discord_id?: string; + telegram_id?: string; + slack_id?: string; + email?: string; } export interface CreateGroupRequest { @@ -1549,6 +1618,7 @@ export const api = { status: () => fetchJson("/status"), overview: () => fetchJson("/overview"), agents: () => fetchJson("/agents"), + factoryPresets: () => fetchJson("/factory/presets"), agentOverview: (agentId: string) => fetchJson(`/agents/overview?agent_id=${encodeURIComponent(agentId)}`), channels: () => fetchJson("/channels"), @@ -1624,6 +1694,18 @@ export const api = { channel_id: channelId ?? null, }), }), + cortexChatThreads: (agentId: string) => + fetchJson( + `/cortex-chat/threads?agent_id=${encodeURIComponent(agentId)}`, + ), + cortexChatDeleteThread: async (agentId: string, threadId: string) => { + const response = await fetch(`${API_BASE}/cortex-chat/thread`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agent_id: agentId, thread_id: threadId }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + }, agentProfile: (agentId: string) => fetchJson(`/agents/profile?agent_id=${encodeURIComponent(agentId)}`), agentIdentity: (agentId: string) => @@ -1651,7 +1733,7 @@ export const api = { return response.json() as Promise<{ success: boolean; agent_id: string; message: string }>; }, - updateAgent: async (agentId: string, update: { display_name?: string; role?: string }) => { + updateAgent: async (agentId: string, update: { display_name?: string; role?: string; gradient_start?: string; gradient_end?: string }) => { const response = await fetch(`${API_BASE}/agents`, { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -1674,6 +1756,35 @@ export const api = { return response.json() as Promise<{ success: boolean; message: string }>; }, + /** Get the avatar URL for an agent (returns the raw URL, not fetched). */ + agentAvatarUrl: (agentId: string) => `${API_BASE}/agents/avatar?agent_id=${encodeURIComponent(agentId)}`, + + /** Upload an avatar image for an agent. */ + uploadAvatar: async (agentId: string, file: File) => { + const params = new URLSearchParams({ agent_id: agentId }); + const response = await fetch(`${API_BASE}/agents/avatar?${params}`, { + method: "POST", + headers: { "Content-Type": file.type }, + body: file, + }); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return response.json() as Promise<{ success: boolean; path?: string; message?: string }>; + }, + + /** Delete the avatar for an agent. */ + deleteAvatar: async (agentId: string) => { + const params = new URLSearchParams({ agent_id: agentId }); + const response = await fetch(`${API_BASE}/agents/avatar?${params}`, { + method: "DELETE", + }); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return response.json() as Promise<{ success: boolean; message: string }>; + }, + agentConfig: (agentId: string) => fetchJson(`/agents/config?agent_id=${encodeURIComponent(agentId)}`), updateAgentConfig: async (request: AgentConfigUpdateRequest) => { diff --git a/interface/src/components/ChannelCard.tsx b/interface/src/components/ChannelCard.tsx index b49be17de..0945b7fdb 100644 --- a/interface/src/components/ChannelCard.tsx +++ b/interface/src/components/ChannelCard.tsx @@ -102,9 +102,12 @@ export function ChannelCard({
-

- {channel.display_name ?? channel.id} -

+

+ {channel.display_name ?? channel.id} + {channel.display_name && ( + {channel.id} + )} +

{isTyping && (
diff --git a/interface/src/components/CortexChatPanel.tsx b/interface/src/components/CortexChatPanel.tsx index c9bd1c4b7..5ec03b031 100644 --- a/interface/src/components/CortexChatPanel.tsx +++ b/interface/src/components/CortexChatPanel.tsx @@ -1,14 +1,21 @@ -import { useEffect, useRef, useState } from "react"; -import { useCortexChat, type ToolActivity } from "@/hooks/useCortexChat"; -import { Markdown } from "@/components/Markdown"; -import { Button } from "@/ui"; -import { PlusSignIcon, Cancel01Icon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; +import {useCallback, useEffect, useRef, useState} from "react"; +import {useCortexChat, type ToolActivity} from "@/hooks/useCortexChat"; +import {Markdown} from "@/components/Markdown"; +import {ToolCall, type ToolCallPair} from "@/components/ToolCall"; +import {api, type CortexChatToolCall, type CortexChatThread} from "@/api/client"; +import {Button} from "@/ui"; +import {Popover, PopoverContent, PopoverTrigger} from "@/ui/Popover"; +import {PlusSignIcon, Cancel01Icon, Clock01Icon, Delete02Icon} from "@hugeicons/core-free-icons"; +import {HugeiconsIcon} from "@hugeicons/react"; interface CortexChatPanelProps { agentId: string; channelId?: string; onClose?: () => void; + /** If set, automatically sent as the first message once the thread is ready. */ + initialPrompt?: string; + /** If true, hides the header bar (useful when embedded in another dialog). */ + hideHeader?: boolean; } interface StarterPrompt { @@ -19,22 +26,79 @@ interface StarterPrompt { const STARTER_PROMPTS: StarterPrompt[] = [ { label: "Run health check", - prompt: "Give me an agent health report with active risks, stale work, and the top 3 fixes to do now.", + prompt: + "Give me an agent health report with active risks, stale work, and the top 3 fixes to do now.", }, { label: "Audit memories", - prompt: "Audit memory quality, find stale or contradictory memories, and propose exact cleanup actions.", + prompt: + "Audit memory quality, find stale or contradictory memories, and propose exact cleanup actions.", }, { label: "Review workers", - prompt: "List recent worker runs, inspect failures, and summarize root cause plus next actions.", + prompt: + "List recent worker runs, inspect failures, and summarize root cause plus next actions.", }, { label: "Draft task spec", - prompt: "Turn this goal into a task spec with subtasks, then move it to ready when it is execution-ready: ", + prompt: + "Turn this goal into a task spec with subtasks, then move it to ready when it is execution-ready: ", }, ]; +/** Convert a persisted CortexChatToolCall to the ToolCallPair format used by + * the ToolCall component. */ +function toToolCallPair(call: CortexChatToolCall): ToolCallPair { + const parsedArgs = tryParseJson(call.args); + const parsedResult = call.result ? tryParseJson(call.result) : null; + return { + id: call.id, + name: call.tool, + argsRaw: call.args, + args: parsedArgs, + resultRaw: call.result ?? null, + result: parsedResult, + status: + call.status === "error" + ? "error" + : call.status === "completed" + ? "completed" + : "running", + }; +} + +/** Convert a live ToolActivity (from SSE streaming) to a ToolCallPair. */ +function activityToToolCallPair(activity: ToolActivity): ToolCallPair { + const parsedArgs = tryParseJson(activity.args); + const parsedResult = activity.result ? tryParseJson(activity.result) : null; + return { + id: activity.call_id, + name: activity.tool, + argsRaw: activity.args, + args: parsedArgs, + resultRaw: activity.result ?? null, + result: parsedResult, + status: activity.status === "done" ? "completed" : "running", + }; +} + +function tryParseJson(text: string): Record | null { + if (!text || text.trim().length === 0) return null; + try { + const parsed = JSON.parse(text); + if ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ) { + return parsed as Record; + } + return null; + } catch { + return null; + } +} + function EmptyCortexState({ channelId, onStarterPrompt, @@ -51,9 +115,12 @@ function EmptyCortexState({ return (
-

Cortex chat

+

+ Cortex chat +

- System-level control for this agent: memory, tasks, worker inspection, and direct tool execution. + System-level control for this agent: memory, tasks, worker inspection, + and direct tool execution.

{contextHint}

@@ -75,28 +142,13 @@ function EmptyCortexState({ ); } -function ToolActivityIndicator({ activity }: { activity: ToolActivity[] }) { +function ToolActivityIndicator({activity}: {activity: ToolActivity[]}) { if (activity.length === 0) return null; return ( -
- {activity.map((tool, index) => ( - - {tool.status === "running" ? ( - - ) : ( - - )} - {tool.tool} - {tool.status === "done" && tool.result_preview && ( - - {tool.result_preview.slice(0, 80)} - - )} - +
+ {activity.map((tool) => ( + ))}
); @@ -104,7 +156,7 @@ function ToolActivityIndicator({ activity }: { activity: ToolActivity[] }) { function ThinkingIndicator() { return ( -
+
@@ -161,11 +213,13 @@ function CortexChatInput({ value={value} onChange={(event) => onChange(event.target.value)} onKeyDown={handleKeyDown} - placeholder={isStreaming ? "Waiting for response..." : "Message the cortex..."} + placeholder={ + isStreaming ? "Waiting for response..." : "Message the cortex..." + } disabled={isStreaming} rows={1} className="flex-1 resize-none bg-transparent px-1 py-1 text-sm text-ink placeholder:text-ink-faint/60 focus:outline-none disabled:opacity-40" - style={{ maxHeight: "160px" }} + style={{maxHeight: "160px"}} /> + )} + + ); + })} +
+ ); +} + +export function CortexChatPanel({ + agentId, + channelId, + onClose, + initialPrompt, + hideHeader, +}: CortexChatPanelProps) { + const { + messages, + threadId, + isStreaming, + error, + toolActivity, + sendMessage, + newThread, + loadThread, + } = useCortexChat(agentId, channelId, {freshThread: !!initialPrompt}); const [input, setInput] = useState(""); + const [threadListOpen, setThreadListOpen] = useState(false); const messagesEndRef = useRef(null); + const initialPromptSentRef = useRef(false); + + // Auto-send initial prompt once the fresh thread is ready + useEffect(() => { + if ( + initialPrompt && + threadId && + !initialPromptSentRef.current && + !isStreaming && + messages.length === 0 + ) { + initialPromptSentRef.current = true; + sendMessage(initialPrompt); + } + }, [initialPrompt, threadId, isStreaming, messages.length, sendMessage]); useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + messagesEndRef.current?.scrollIntoView({behavior: "smooth"}); }, [messages.length, isStreaming, toolActivity.length]); const handleSubmit = () => { @@ -213,18 +407,49 @@ export function CortexChatPanel({ agentId, channelId, onClose }: CortexChatPanel }; return ( -
+
{/* Header */} -
-
- Cortex - {channelId && ( - - {channelId.length > 20 ? `${channelId.slice(0, 20)}...` : channelId} - - )} -
+ {!hideHeader && ( +
+
+ Cortex + {channelId && ( + + {channelId.length > 20 + ? `${channelId.slice(0, 20)}...` + : channelId} + + )} +
+ + + + + +
+ Threads +
+ setThreadListOpen(false)} + /> +
+
-
+
+ )} {/* Messages */} -
+
- {messages.map((message) => ( -
- {message.role === "user" ? ( -
-
-

{message.content}

-
-
- ) : ( -
- {message.content} + {messages.map((message) => ( +
+ {message.role === "user" ? ( +
+
+

{message.content}

- )} -
- ))} +
+ ) : ( +
+ {message.tool_calls && message.tool_calls.length > 0 && ( +
+ {message.tool_calls.map((call) => ( + + ))} +
+ )} + {message.content && ( +
+ {message.content} +
+ )} +
+ )} +
+ ))} {/* Streaming state */} {isStreaming && (
- {toolActivity.length === 0 && } + {!toolActivity.some((t) => t.status === "running") && ( + + )}
)} diff --git a/interface/src/components/CreateAgentDialog.tsx b/interface/src/components/CreateAgentDialog.tsx index 024c06f65..4a4c9aa89 100644 --- a/interface/src/components/CreateAgentDialog.tsx +++ b/interface/src/components/CreateAgentDialog.tsx @@ -1,125 +1,187 @@ -import {useState} from "react"; -import {useMutation, useQueryClient} from "@tanstack/react-query"; -import {useNavigate} from "@tanstack/react-router"; -import {api} from "@/api/client"; -import {Button, Input, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter} from "@/ui"; +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { api, type PresetMeta } from "@/api/client"; +import { Dialog, DialogContent } from "@/ui"; +import { CortexChatPanel } from "@/components/CortexChatPanel"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faRobot, + faCode, + faMagnifyingGlass, + faHeadset, + faPen, + faHandshake, + faBriefcase, + faUsers, + faTableColumns, + faPlus, +} from "@fortawesome/free-solid-svg-icons"; +import type { IconDefinition } from "@fortawesome/fontawesome-svg-core"; interface CreateAgentDialogProps { open: boolean; onOpenChange: (open: boolean) => void; + /** The agent ID whose cortex will handle the factory flow. */ + agentId: string; } -export function CreateAgentDialog({open, onOpenChange}: CreateAgentDialogProps) { - const [agentId, setAgentId] = useState(""); - const [displayName, setDisplayName] = useState(""); - const [role, setRole] = useState(""); - const [error, setError] = useState(null); - const queryClient = useQueryClient(); - const navigate = useNavigate(); +const PRESET_ICONS: Record = { + bot: faRobot, + code: faCode, + search: faMagnifyingGlass, + headset: faHeadset, + pen: faPen, + handshake: faHandshake, + briefcase: faBriefcase, + users: faUsers, + kanban: faTableColumns, +}; - const createMutation = useMutation({ - mutationFn: (params: { id: string; displayName: string; role: string }) => - api.createAgent(params.id, params.displayName, params.role), - onSuccess: (result) => { - if (result.success) { - queryClient.invalidateQueries({queryKey: ["agents"]}); - queryClient.invalidateQueries({queryKey: ["overview"]}); - queryClient.invalidateQueries({queryKey: ["topology"]}); - onOpenChange(false); - setAgentId(""); - setDisplayName(""); - setRole(""); - setError(null); - navigate({to: "/agents/$agentId", params: {agentId: result.agent_id}}); - } else { - setError(result.message); - } - }, - onError: (err) => setError(`Failed: ${err.message}`), +function PresetCard({ + preset, + onClick, +}: { + preset: PresetMeta; + onClick: () => void; +}) { + const icon = PRESET_ICONS[preset.icon] ?? faRobot; + + return ( + + ); +} + +function ScratchCard({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +export function CreateAgentDialog({ open, onOpenChange, agentId }: CreateAgentDialogProps) { + const [selectedPreset, setSelectedPreset] = useState(null); + const [chatPrompt, setChatPrompt] = useState(null); + + const { data } = useQuery({ + queryKey: ["factory-presets"], + queryFn: api.factoryPresets, + enabled: open, }); - function handleSubmit() { - const trimmed = agentId.trim().toLowerCase().replace(/[^a-z0-9_-]/g, ""); - if (!trimmed) { - setError("Agent ID is required"); - return; - } - setError(null); - createMutation.mutate({ - id: trimmed, - displayName: displayName.trim(), - role: role.trim(), - }); + const presets = data?.presets ?? []; + + function handlePresetClick(preset: PresetMeta) { + setSelectedPreset(preset.id); + setChatPrompt( + `I would like to create a new agent using the "${preset.name}" preset (${preset.id}). ` + + `Load the preset, walk me through customizing it for my needs, and create the agent when ready.` + ); + } + + function handleScratchClick() { + setSelectedPreset("scratch"); + setChatPrompt( + "I would like to create a new agent from scratch. " + + "Ask me what I need, recommend a preset if one fits, and walk me through the creation process." + ); } function handleClose() { - setError(null); - setAgentId(""); - setDisplayName(""); - setRole(""); + setSelectedPreset(null); + setChatPrompt(null); } return ( - { if (!v) handleClose(); onOpenChange(v); }}> - - - Create Agent - -
-
- - setAgentId(e.target.value)} - placeholder="e.g. research, support, dev" - onKeyDown={(e) => { if (e.key === "Enter") handleSubmit(); }} - autoFocus - /> -

- Lowercase letters, numbers, hyphens, and underscores only. -

-
-
- - setDisplayName(e.target.value)} - placeholder="e.g. Research Agent" - onKeyDown={(e) => { if (e.key === "Enter") handleSubmit(); }} - /> -
-
- - setRole(e.target.value)} - placeholder="e.g. Handles tier 1 support tickets" - onKeyDown={(e) => { if (e.key === "Enter") handleSubmit(); }} - /> + { + if (!v) handleClose(); + onOpenChange(v); + }} + > + + {!chatPrompt ? ( + /* ---- Preset picker ---- */ +
+
+

+ Create Agent +

+

+ Choose a preset to get started, or start from scratch. +

+
+
+
+ {presets.map((preset) => ( + handlePresetClick(preset)} + /> + ))} + +
+
- {error && ( -
- {error} + ) : ( + /* ---- Cortex chat with factory context ---- */ +
+
+
+ + + {selectedPreset === "scratch" + ? "New Agent" + : presets.find((p) => p.id === selectedPreset)?.name ?? "New Agent"} + +
- )} -
- - - - +
+ +
+
+ )}
); diff --git a/interface/src/components/ProfileAvatar.tsx b/interface/src/components/ProfileAvatar.tsx new file mode 100644 index 000000000..3a90ac377 --- /dev/null +++ b/interface/src/components/ProfileAvatar.tsx @@ -0,0 +1,99 @@ +/** Deterministic gradient avatar with initials, custom gradient, or uploaded image. */ + +import { useEffect, useState } from "react"; + +export function seedGradient(seed: string): [string, string] { + let hash = 0; + for (let i = 0; i < seed.length; i++) { + hash = seed.charCodeAt(i) + ((hash << 5) - hash); + hash |= 0; + } + const hue1 = (hash >>> 0) % 360; + const hue2 = (hue1 + 40 + ((hash >>> 8) % 60)) % 360; + return [`hsl(${hue1}, 70%, 55%)`, `hsl(${hue2}, 60%, 45%)`]; +} + +interface ProfileAvatarProps { + /** Seed for the gradient (typically the node/agent ID). */ + seed: string; + /** Display name to derive initials from. Falls back to seed. */ + name: string; + /** Pixel size of the avatar. Default 48. */ + size?: number; + /** Extra CSS classes on the outer element. */ + className?: string; + /** Custom gradient start color (overrides seed gradient). */ + gradientStart?: string; + /** Custom gradient end color (overrides seed gradient). */ + gradientEnd?: string; + /** URL to a custom avatar image (takes precedence over gradient). */ + avatarUrl?: string; +} + +export function ProfileAvatar({ + seed, + name, + size = 48, + className, + gradientStart, + gradientEnd, + avatarUrl, +}: ProfileAvatarProps) { + const [imgFailed, setImgFailed] = useState(false); + + // Reset failure state when the URL changes (e.g. after uploading a new avatar). + useEffect(() => setImgFailed(false), [avatarUrl]); + + // If an uploaded avatar exists and hasn't failed to load, render an img. + if (avatarUrl && !imgFailed) { + return ( + {name} setImgFailed(true)} + /> + ); + } + + const [defaultC1, defaultC2] = seedGradient(seed); + const c1 = gradientStart || defaultC1; + const c2 = gradientEnd || defaultC2; + const gradientId = `av-${seed}`; + const initials = name.slice(0, 2).toUpperCase(); + const fontSize = 22; + + return ( + + + + + + + + + + {initials} + + + ); +} diff --git a/interface/src/components/Sidebar.tsx b/interface/src/components/Sidebar.tsx index 863b9bc48..66330aaca 100644 --- a/interface/src/components/Sidebar.tsx +++ b/interface/src/components/Sidebar.tsx @@ -26,6 +26,7 @@ import { Button } from "@/ui"; import { ArrowLeft01Icon, DashboardSquare01Icon, Settings01Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { CreateAgentDialog } from "@/components/CreateAgentDialog"; +import { ProfileAvatar } from "@/components/ProfileAvatar"; interface SidebarProps { liveStates: Record; @@ -36,12 +37,14 @@ interface SidebarProps { interface SortableAgentItemProps { agentId: string; displayName?: string; + gradientStart?: string; + gradientEnd?: string; activity?: { workers: number; branches: number }; isActive: boolean; collapsed: boolean; } -function SortableAgentItem({ agentId, displayName, activity, isActive, collapsed }: SortableAgentItemProps) { +function SortableAgentItem({ agentId, displayName, gradientStart, gradientEnd, activity, isActive, collapsed }: SortableAgentItemProps) { const { attributes, listeners, @@ -64,13 +67,11 @@ function SortableAgentItem({ agentId, displayName, activity, isActive, collapsed - {(displayName ?? agentId).charAt(0).toUpperCase()} +
); @@ -88,6 +89,7 @@ function SortableAgentItem({ agentId, displayName, activity, isActive, collapsed }`} style={{ pointerEvents: isDragging ? 'none' : 'auto' }} > + {displayName ?? agentId} {activity && (activity.workers > 0 || activity.branches > 0) && (
@@ -123,6 +125,14 @@ export function Sidebar({ liveStates, collapsed, onToggle }: SidebarProps) { refetchInterval: 10_000, }); + const { data: providersData } = useQuery({ + queryKey: ["providers"], + queryFn: api.providers, + staleTime: 10_000, + }); + + const hasProvider = providersData?.has_any ?? false; + const agents = agentsData?.agents ?? []; const channels = channelsData?.channels ?? []; @@ -132,6 +142,11 @@ export function Sidebar({ liveStates, collapsed, onToggle }: SidebarProps) { for (const a of agents) map[a.id] = a.display_name; return map; }, [agents]); + const agentGradients = useMemo(() => { + const map: Record = {}; + for (const a of agents) map[a.id] = { start: a.gradient_start, end: a.gradient_end }; + return map; + }, [agents]); const [agentOrder, setAgentOrder] = useAgentOrder(agentIds); const matchRoute = useMatchRoute(); @@ -238,6 +253,8 @@ export function Sidebar({ liveStates, collapsed, onToggle }: SidebarProps) { key={agentId} agentId={agentId} displayName={agentDisplayNames[agentId]} + gradientStart={agentGradients[agentId]?.start} + gradientEnd={agentGradients[agentId]?.end} isActive={isActive} collapsed={true} /> @@ -245,13 +262,15 @@ export function Sidebar({ liveStates, collapsed, onToggle }: SidebarProps) { })} - + {hasProvider && ( + + )}
) : ( <> @@ -305,6 +324,8 @@ export function Sidebar({ liveStates, collapsed, onToggle }: SidebarProps) { key={agentId} agentId={agentId} displayName={agentDisplayNames[agentId]} + gradientStart={agentGradients[agentId]?.start} + gradientEnd={agentGradients[agentId]?.end} activity={activity} isActive={isActive} collapsed={false} @@ -315,18 +336,22 @@ export function Sidebar({ liveStates, collapsed, onToggle }: SidebarProps) { )} - + {hasProvider && ( + + )}
)} - + {agents[0] && ( + + )} ); } diff --git a/interface/src/components/TopologyGraph.tsx b/interface/src/components/TopologyGraph.tsx index aac095aa9..117b4c3e5 100644 --- a/interface/src/components/TopologyGraph.tsx +++ b/interface/src/components/TopologyGraph.tsx @@ -33,7 +33,8 @@ import { type LinkDirection, type LinkKind, } from "@/api/client"; -import { Button, Input, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/ui"; +import { Button, Input, TextArea, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, cx } from "@/ui"; +import { Markdown } from "@/components/Markdown"; import { Link } from "@tanstack/react-router"; // -- Colors -- @@ -97,16 +98,8 @@ function loadHandles(): SavedHandles { } /** Deterministic gradient from a seed string. */ -function seedGradient(seed: string): [string, string] { - let hash = 0; - for (let i = 0; i < seed.length; i++) { - hash = seed.charCodeAt(i) + ((hash << 5) - hash); - hash |= 0; - } - const hue1 = (hash >>> 0) % 360; - const hue2 = (hue1 + 40 + ((hash >>> 8) % 60)) % 360; - return [`hsl(${hue1}, 70%, 55%)`, `hsl(${hue2}, 60%, 45%)`]; -} +// Re-export from shared module so existing references (e.g. banner gradient) still work. +import { seedGradient, ProfileAvatar } from "@/components/ProfileAvatar"; // -- Custom Node: Group -- @@ -158,11 +151,16 @@ const NODE_WIDTH = 240; function ProfileNode({ data, selected }: NodeProps) { const nodeId = (data.nodeId as string) ?? ""; const avatarSeed = (data.avatarSeed as string) ?? nodeId; - const [c1, c2] = seedGradient(avatarSeed); + const [defaultC1, defaultC2] = seedGradient(avatarSeed); const configDisplayName = data.configDisplayName as string | null; const configRole = data.configRole as string | null; const chosenName = data.chosenName as string | null; const bio = data.bio as string | null; + const gradientStart = data.gradientStart as string | null; + const gradientEnd = data.gradientEnd as string | null; + const nodeAvatarUrl = data.avatarUrl as string | null; + const c1 = gradientStart || defaultC1; + const c2 = gradientEnd || defaultC2; const isOnline = data.isOnline as boolean; const channelCount = (data.channelCount as number) ?? 0; const memoryCount = (data.memoryCount as number) ?? 0; @@ -222,26 +220,15 @@ function ProfileNode({ data, selected }: NodeProps) { {/* Avatar + badge row */}
- - - - - - - - - + gradientStart={gradientStart ?? undefined} + gradientEnd={gradientEnd ?? undefined} + avatarUrl={isAgent && nodeAvatarUrl ? nodeAvatarUrl : undefined} + /> {isAgent && (
)} - {/* Bio */} - {bio && ( + {/* Bio */} + {bio ? (

{bio}

+ ) : !isAgent && !data.description && ( +

+ This is you, add your details. +

)} {/* Stats (agents only) */} @@ -538,10 +529,29 @@ function EdgeConfigPanel({ // -- Human Edit Dialog -- interface HumanEditDialogProps { - human: { id: string; display_name?: string; role?: string; bio?: string } | null; + human: { + id: string; + display_name?: string; + role?: string; + bio?: string; + description?: string; + discord_id?: string; + telegram_id?: string; + slack_id?: string; + email?: string; + } | null; open: boolean; onOpenChange: (open: boolean) => void; - onUpdate: (displayName: string, role: string, bio: string) => void; + onUpdate: (fields: { + displayName: string; + role: string; + bio: string; + description: string; + discordId: string; + telegramId: string; + slackId: string; + email: string; + }) => void; onDelete: () => void; } @@ -555,6 +565,17 @@ function HumanEditDialog({ const [displayName, setDisplayName] = useState(""); const [role, setRole] = useState(""); const [bio, setBio] = useState(""); + const [description, setDescription] = useState(""); + const [discordId, setDiscordId] = useState(""); + const [telegramId, setTelegramId] = useState(""); + const [slackId, setSlackId] = useState(""); + const [email, setEmail] = useState(""); + const [descriptionMode, setDescriptionMode] = useState<"edit" | "preview">("edit"); + + const { data: messagingStatus } = useQuery({ + queryKey: ["messagingStatus"], + queryFn: api.messagingStatus, + }); // Sync state when a different human is selected const prevId = useRef(null); @@ -563,59 +584,171 @@ function HumanEditDialog({ setDisplayName(human.display_name ?? ""); setRole(human.role ?? ""); setBio(human.bio ?? ""); + setDescription(human.description ?? ""); + setDiscordId(human.discord_id ?? ""); + setTelegramId(human.telegram_id ?? ""); + setSlackId(human.slack_id ?? ""); + setEmail(human.email ?? ""); + setDescriptionMode("edit"); } if (!human) return null; return ( - - - Edit Human - -
-
{human.id}
-
- - setDisplayName(e.target.value)} - placeholder={human.id} - /> + +
+
+ Edit Human + {human.id}
-
- - setRole(e.target.value)} - placeholder="e.g. CEO, Lead Developer" - /> +
+
+ {/* Left column — metadata fields */} +
+
+ + setDisplayName(e.target.value)} + placeholder={human.id} + /> +
+
+ + setRole(e.target.value)} + placeholder="e.g. CEO, Lead Developer" + /> +
+
+ +