diff --git a/.claude/skills/add-deltachat/SKILL.md b/.claude/skills/add-deltachat/SKILL.md index 45aa416..3dd5df6 100644 --- a/.claude/skills/add-deltachat/SKILL.md +++ b/.claude/skills/add-deltachat/SKILL.md @@ -140,7 +140,7 @@ After accepting, DeltaChat exchanges keys and creates the chat automatically. Once the first message arrives the router auto-creates a `messaging_groups` row. Look up the chat ID: ```bash -sqlite3 data/v2.db \ +pnpm exec tsx scripts/q.ts data/v2.db \ "SELECT platform_id, name FROM messaging_groups WHERE channel_type='deltachat' AND is_group=0 ORDER BY created_at DESC LIMIT 5" ``` @@ -226,7 +226,7 @@ Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart. 1. Check the service is running and the adapter started: `grep "Channel adapter started.*deltachat" logs/nanoclaw.log` 2. Check connectivity: `grep "DeltaChat: IO started" logs/nanoclaw.log` 3. Check the sender has been granted access — run `/init-first-agent` to create their user record and wire the chat -4. Verify the messaging group is wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"` +4. Verify the messaging group is wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"` ### Stale lock file after crash @@ -248,7 +248,7 @@ grep "DeltaChat" logs/nanoclaw.error.log | tail -20 The messaging group exists but may not be wired to an agent group. Run: ```bash -sqlite3 data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'" +pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'" ``` If the group has no entry in `messaging_group_agents`, wire it with `/manage-channels`. diff --git a/.claude/skills/add-deltachat/VERIFY.md b/.claude/skills/add-deltachat/VERIFY.md index 839fa85..ae25c58 100644 --- a/.claude/skills/add-deltachat/VERIFY.md +++ b/.claude/skills/add-deltachat/VERIFY.md @@ -37,7 +37,7 @@ grep "DeltaChat" logs/nanoclaw.error.log | tail -10 ## 4. Check messaging group was created ```bash -sqlite3 data/v2.db \ +pnpm exec tsx scripts/q.ts data/v2.db \ "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat' ORDER BY created_at DESC LIMIT 5" ``` @@ -48,7 +48,7 @@ If a row appears, the inbound routing is working. If not, the adapter isn't rece If the message arrived but the agent didn't respond, the sender may not have access: ```bash -sqlite3 data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'" +pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'" ``` Grant access as shown in the SKILL.md "Grant user access" section. diff --git a/.claude/skills/add-emacs/SKILL.md b/.claude/skills/add-emacs/SKILL.md index 82a5098..4a24eca 100644 --- a/.claude/skills/add-emacs/SKILL.md +++ b/.claude/skills/add-emacs/SKILL.md @@ -241,7 +241,7 @@ grep -q "import './emacs.js'" src/channels/index.ts && echo "imported" || echo " ### No response from agent 1. NanoClaw running: `launchctl list | grep nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux) -2. Messaging group wired: `sqlite3 data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"` +2. Messaging group wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"` 3. Logs show inbound: `grep 'channel_type=emacs\|Emacs' logs/nanoclaw.log | tail -20` If no messaging group row exists, run the `register` command above. @@ -292,5 +292,5 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Remove the NanoClaw block from your Emacs config # Optionally clean up the messaging group: -sqlite3 data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';" +pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';" ``` diff --git a/.claude/skills/add-gmail-tool/SKILL.md b/.claude/skills/add-gmail-tool/SKILL.md index 095c285..03df0e2 100644 --- a/.claude/skills/add-gmail-tool/SKILL.md +++ b/.claude/skills/add-gmail-tool/SKILL.md @@ -82,11 +82,14 @@ For each target agent group, confirm OneCLI will inject Gmail secrets into its c onecli agents list ``` -If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets: +If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets using the safe merge pattern (`set-secrets` replaces the entire list — always read first): ```bash -onecli secrets list # find Gmail secret IDs (OneCLI creates one per connected app) -onecli agents set-secrets --id --secret-ids +GMAIL_IDS=$(onecli secrets list | jq -r '[.data[] | select(.name | test("(?i)gmail")) | .id] | join(",")') +CURRENT=$(onecli agents secrets --id | jq -r '[.data[]] | join(",")') +MERGED=$(printf '%s' "$CURRENT,$GMAIL_IDS" | tr ',' '\n' | sort -u | paste -sd ',' -) +onecli agents set-secrets --id --secret-ids "$MERGED" +onecli agents secrets --id ``` ## Phase 2: Apply Code Changes diff --git a/.claude/skills/add-karpathy-llm-wiki/SKILL.md b/.claude/skills/add-karpathy-llm-wiki/SKILL.md index 12b9b37..79bfed9 100644 --- a/.claude/skills/add-karpathy-llm-wiki/SKILL.md +++ b/.claude/skills/add-karpathy-llm-wiki/SKILL.md @@ -71,38 +71,11 @@ AskUserQuestion: "Want periodic wiki health checks?" 2. **Monthly** 3. **Skip** — lint manually -If yes, create a NanoClaw scheduled task that runs in the wiki group. This is NOT a Claude Code cron job — it's a NanoClaw group task that runs in the agent container. Insert it into the SQLite database: +If yes, ask the agent to schedule the lint task using the `schedule_task` MCP tool in conversation. + +## Step 6: Restart ```bash -pnpm exec tsx -e " -const Database = require('better-sqlite3'); -const { CronExpressionParser } = require('cron-parser'); -const db = new Database('store/messages.db'); -const interval = CronExpressionParser.parse('', { tz: process.env.TZ || 'UTC' }); -const nextRun = interval.next().toISOString(); -db.prepare('INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run( - 'wiki-lint', - '', - '', - 'Run a wiki lint pass per the wiki container skill. Check for contradictions, orphan pages, stale content, missing cross-references, and gaps. Report findings and offer to fix issues.', - 'cron', - '', - 'group', - nextRun, - 'active', - new Date().toISOString() -); -db.close(); -" -``` - -Use the group's `folder` and `chat_jid` from the registered groups table. Cron expressions: `0 10 * * 0` (weekly Sunday 10am) or `0 10 1 * *` (monthly 1st at 10am). - -## Step 6: Build and restart - -```bash -pnpm run build -./container/build.sh launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw ``` diff --git a/.claude/skills/add-mnemon/SKILL.md b/.claude/skills/add-mnemon/SKILL.md new file mode 100644 index 0000000..db0d029 --- /dev/null +++ b/.claude/skills/add-mnemon/SKILL.md @@ -0,0 +1,208 @@ +--- +name: add-mnemon +description: Add persistent graph-based memory via mnemon. Agents recall past context before responding and remember insights after each turn. +--- + +# Add Mnemon — Persistent Memory + +Installs [mnemon](https://github.com/mnemon-dev/mnemon) in the agent container image. On each container start, `mnemon setup` registers Claude Code hooks that surface relevant memory before the agent responds and store new insights after each turn. Memory is written to the per-agent-group `.claude/` mount and survives container restarts. + +## Provider Compatibility + +**mnemon hooks only work with `--target claude-code`.** If the agent group uses `AGENT_PROVIDER=opencode`, hooks registered by `mnemon setup` will never fire — OpenCode spawns its own process and doesn't invoke the `claude` CLI at all. + +Check your provider: + +```bash +grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null +``` + +- `AGENT_PROVIDER=claude` (default) — fully compatible, proceed with both Phase 2 steps. +- `AGENT_PROVIDER=opencode` — use **Phase 2 (OpenCode path)** instead of the standard entrypoint step. + +## Phase 1: Pre-flight + +### Check if already applied + +```bash +grep -q 'MNEMON_VERSION' container/Dockerfile && echo "Already applied" || echo "Not applied" +``` + +If already applied, skip to Phase 3 (Verify). + +### Check latest mnemon version + +```bash +curl -fsSL https://api.github.com/repos/mnemon-dev/mnemon/releases/latest | grep '"tag_name"' +``` + +Note the version (e.g. `v0.1.1`) — use it as `MNEMON_VERSION` in the next step. + +## Phase 2: Apply Changes (Claude Code path) + +### 1. Dockerfile — install mnemon binary + +Add after the AWS CLI block, before the Bun runtime section: + +```dockerfile +# ---- mnemon — persistent agent memory ---------------------------------------- +ARG MNEMON_VERSION=0.1.1 +RUN ARCH=$(dpkg --print-architecture) && \ + curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \ + | tar -xz -C /usr/local/bin mnemon && \ + chmod +x /usr/local/bin/mnemon + +ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon +``` + +`MNEMON_DATA_DIR` points into the per-agent-group `.claude/` mount so memory persists across container restarts. No extra volume mounts needed. + +### 2. Entrypoint — run mnemon setup on each container start + +`mnemon setup` is idempotent. Edit `container/entrypoint.sh` to run it right after `set -e`, before the `cat` that captures stdin: + +```bash +#!/bin/bash +# NanoClaw agent container entrypoint. +# +# ...existing header comment... + +set -e + +mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1 + +cat > /tmp/input.json + +exec bun run /app/src/index.ts < /tmp/input.json +``` + +`>/dev/stderr 2>&1` routes all mnemon output to stderr (docker logs) so it doesn't interfere with the JSON stdin handshake between host and agent-runner. + +### 3. Rebuild and smoke-test the image + +```bash +./container/build.sh +docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version +``` + +## Phase 3: Restart and Verify + +### Restart the service + +```bash +systemctl --user restart nanoclaw # Linux +# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +``` + +### Confirm mnemon hooks are registered + +After the next container starts, check that setup ran: + +```bash +docker logs $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) 2>&1 | grep -i mnemon +``` + +Then inspect the hooks inside the running container: + +```bash +docker exec $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \ + cat /home/node/.claude/settings.json | grep -A5 mnemon +``` + +### Test memory recall + +Have a conversation with the agent, then start a new session and reference something from the earlier one. Mnemon should surface the relevant context automatically without you restating it. + +## Phase 2 (OpenCode path) — context injection + +mnemon hooks don't fire under OpenCode. Instead, the agent-runner injects mnemon context directly into every prompt via `wrapPromptWithContext()` in `container/agent-runner/src/providers/opencode.ts`. This is already implemented in NanoClaw — no code changes needed if you're on current `ester`/`main`. + +**How it works:** On each prompt, `readMnemonContext()` checks for `MNEMON_DATA_DIR` (set by the Dockerfile `ENV`). If the env var is present, it reads `$MNEMON_DATA_DIR/prompt/guide.md` (mnemon's custom prompt guide, written by `mnemon setup`) or falls back to an inline guide. The content is prepended as a `` block, instructing the agent to run `mnemon recall` at the start of relevant tasks and `mnemon remember` after key decisions. + +**What this means for the agent:** The agent (running inside OpenCode) can call `mnemon recall`, `mnemon remember`, `mnemon link`, and `mnemon status` via its bash tool. mnemon writes its graph to `$MNEMON_DATA_DIR`, which is in the per-agent-group `.claude/` mount — so memory persists across container restarts. + +**Applying:** Only the Dockerfile step from Phase 2 is needed for OpenCode agents. Skip `container/entrypoint.sh` entirely. + +```dockerfile +ARG MNEMON_VERSION=0.1.1 +RUN ARCH=$(dpkg --print-architecture) && \ + curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \ + | tar -xz -C /usr/local/bin mnemon && \ + chmod +x /usr/local/bin/mnemon +ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon +``` + +Then rebuild: `./container/build.sh` + +### Verify (OpenCode) + +Start a session and ask the agent to run `mnemon status`. It should report empty graphs (no error) on first run. + +```bash +# Also confirm the binary is present in the image: +docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version +``` + +## Memory Storage + +Mnemon writes to `/home/node/.claude/mnemon/` inside the container, which maps to the per-agent-group `.claude/` directory on the host. To find the exact host path: + +```bash +docker inspect $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \ + --format '{{range .Mounts}}{{if eq .Destination "/home/node/.claude"}}{{.Source}}{{end}}{{end}}' +``` + +To reset all memory for an agent, stop the container and delete the `mnemon/` subdirectory from that host path. + +## Migration Guide Update + +If you are using `/migrate-nanoclaw`, add these entries to `.nanoclaw-migrations/05-dockerfile.md`: + +**Dockerfile — after AWS CLI, before Bun runtime:** +```dockerfile +ARG MNEMON_VERSION=0.1.1 +RUN ARCH=$(dpkg --print-architecture) && \ + curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \ + | tar -xz -C /usr/local/bin mnemon && \ + chmod +x /usr/local/bin/mnemon +ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon +``` + +**`container/entrypoint.sh` — add after `set -e`:** +```bash +mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1 +``` + +## Troubleshooting + +### `mnemon: command not found` in container + +The image wasn't rebuilt after adding the Dockerfile layer. Run `./container/build.sh` and restart. + +### Memory not persisting across restarts + +Verify `MNEMON_DATA_DIR` resolves to a mounted path (not an in-container ephemeral directory): + +```bash +docker exec sh -c 'ls -la $MNEMON_DATA_DIR' +``` + +If the directory is empty after conversations, the mount is missing or the path is wrong. Check the host mount with the `docker inspect` command above. + +### Agent not using past memory + +`mnemon setup` writes hooks into `/home/node/.claude/settings.json`. Verify: + +```bash +docker exec cat /home/node/.claude/settings.json +``` + +If the hooks are absent, `mnemon setup` may have failed silently. Check container startup logs for errors from mnemon. + +### Setup fails at container start + +Run setup manually inside a running container to see the full error: + +```bash +docker exec -it mnemon setup --target claude-code --yes --global +``` diff --git a/.claude/skills/add-ollama-provider/SKILL.md b/.claude/skills/add-ollama-provider/SKILL.md index 83f7e5a..fe42249 100644 --- a/.claude/skills/add-ollama-provider/SKILL.md +++ b/.claude/skills/add-ollama-provider/SKILL.md @@ -76,7 +76,7 @@ Then rebuild the container image: `./container/build.sh` Ask the user (plain text, not AskUserQuestion): -1. **Which agent group?** List available groups: `sqlite3 data/v2.db "SELECT folder, name FROM agent_groups;"` +1. **Which agent group?** List available groups: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT folder, name FROM agent_groups;"` 2. **Which Ollama model?** List available: `curl -s http://localhost:11434/api/tags | grep '"name"'` 3. **Block Anthropic API?** Recommended yes — prevents accidental spend if config drifts. @@ -111,7 +111,7 @@ Read the agent group's shared Claude settings: ```bash # Find the agent group ID -AG_ID=$(sqlite3 data/v2.db "SELECT id FROM agent_groups WHERE folder='';") +AG_ID=$(pnpm exec tsx scripts/q.ts data/v2.db "SELECT id FROM agent_groups WHERE folder='';") SETTINGS=data/v2-sessions/$AG_ID/.claude-shared/settings.json ``` diff --git a/.claude/skills/add-opencode/SKILL.md b/.claude/skills/add-opencode/SKILL.md index 555f0fe..841baaa 100644 --- a/.claude/skills/add-opencode/SKILL.md +++ b/.claude/skills/add-opencode/SKILL.md @@ -132,12 +132,15 @@ Credentials: register provider API keys in OneCLI with the matching `--host-patt After adding a secret, **grant the agent access** — agents in `selective` mode only receive secrets they've been explicitly assigned: -```bash -# Find the agent id and secret id, then: -onecli agents set-secrets --id --secret-ids , -``` +Use the safe merge pattern — `set-secrets` replaces the entire list, so always read first: -Always include existing secret IDs in the list — `set-secrets` replaces, not appends. +```bash +AGENT_ID=$(onecli agents list | jq -r '.data[] | select(.identifier=="") | .id') +CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")') +MERGED=$(printf '%s' "$CURRENT," | tr ',' '\n' | sort -u | paste -sd ',' -) +onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED" +onecli agents secrets --id "$AGENT_ID" +``` #### Example: DeepSeek diff --git a/.claude/skills/add-parallel/SKILL.md b/.claude/skills/add-parallel/SKILL.md index a9dff8f..c391f53 100644 --- a/.claude/skills/add-parallel/SKILL.md +++ b/.claude/skills/add-parallel/SKILL.md @@ -275,7 +275,7 @@ Look for: `Parallel AI MCP servers configured` - Check agent-runner logs for "Parallel AI MCP servers configured" message **Task polling not working:** -- Verify scheduled task was created: `sqlite3 store/messages.db "SELECT * FROM scheduled_tasks"` +- Verify scheduled task was created: `pnpm exec tsx scripts/q.ts store/messages.db "SELECT * FROM scheduled_tasks"` - Check task runs: `tail -f logs/nanoclaw.log | grep "scheduled task"` - Ensure task prompt includes proper Parallel MCP tool names diff --git a/.claude/skills/add-signal/SKILL.md b/.claude/skills/add-signal/SKILL.md index 7dcc8ad..6b63a5c 100644 --- a/.claude/skills/add-signal/SKILL.md +++ b/.claude/skills/add-signal/SKILL.md @@ -200,7 +200,7 @@ systemctl --user restart nanoclaw After the service starts, send any message to the Signal number from your personal Signal app. The router auto-creates a `messaging_groups` row. Then: ```bash -sqlite3 data/v2.db \ +pnpm exec tsx scripts/q.ts data/v2.db \ "SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5" ``` @@ -212,7 +212,7 @@ Add the Signal number to a group from your phone, send any message, then wire th ```bash NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") -sqlite3 data/v2.db " +pnpm exec tsx scripts/q.ts data/v2.db " INSERT OR IGNORE INTO messaging_group_agents (id, messaging_group_id, agent_group_id, session_mode, priority, created_at) VALUES @@ -226,7 +226,7 @@ New Signal users (including the owner's Signal identity) are silently dropped wi ```bash NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") -sqlite3 data/v2.db " +pnpm exec tsx scripts/q.ts data/v2.db " INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at) VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW'); INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at) @@ -282,8 +282,13 @@ If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DA ### Bot not responding 1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1` -2. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"` +2. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"` 3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux) +4. **Check for duplicate service instances** — if `logs/nanoclaw.error.log` shows `No adapter for channel type channelType="signal"` despite the adapter starting, two NanoClaw processes are racing. See the `/debug` skill section "No adapter for channel type / Messages silently lost" for the full fix. + +### Messages delivered but never arrive (null platformMsgId) + +Signal responses show `platformMsgId=undefined` in the main log. This means the delivery poll ran but found no adapter — likely a duplicate service instance issue (see above). Affected messages cannot be retried; the user must resend. ### Lost connection mid-session diff --git a/.claude/skills/add-vercel/SKILL.md b/.claude/skills/add-vercel/SKILL.md index dbd9780..be3b201 100644 --- a/.claude/skills/add-vercel/SKILL.md +++ b/.claude/skills/add-vercel/SKILL.md @@ -90,12 +90,12 @@ onecli secrets list | grep -i vercel OneCLI uses selective secret mode — secrets must be explicitly assigned to each agent. Get the Vercel secret ID from the output above, then assign it to every agent: ```bash -# For each agent, add the Vercel secret to its assigned secrets list. -# First get current assignments, then set them with the new secret appended. -VERCEL_SECRET_ID=$(onecli secrets list 2>/dev/null | grep -B2 "Vercel" | grep '"id"' | head -1 | sed 's/.*"id": "//;s/".*//') -for agent in $(onecli agents list 2>/dev/null | grep '"id"' | sed 's/.*"id": "//;s/".*//'); do - CURRENT=$(onecli agents secrets --id "$agent" 2>/dev/null | grep '"' | grep -v hint | grep -v data | sed 's/.*"//;s/".*//' | tr '\n' ',' | sed 's/,$//') - onecli agents set-secrets --id "$agent" --secret-ids "${CURRENT:+$CURRENT,}$VERCEL_SECRET_ID" +# set-secrets replaces the entire list — read and merge for each agent. +VERCEL_SECRET_ID=$(onecli secrets list | jq -r '.data[] | select(.name | test("(?i)vercel")) | .id' | head -1) +for agent in $(onecli agents list | jq -r '.data[].id'); do + CURRENT=$(onecli agents secrets --id "$agent" | jq -r '[.data[]] | join(",")') + MERGED=$(printf '%s' "$CURRENT,$VERCEL_SECRET_ID" | tr ',' '\n' | sort -u | paste -sd ',' -) + onecli agents set-secrets --id "$agent" --secret-ids "$MERGED" done ``` diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md index 232725f..edec479 100644 --- a/.claude/skills/add-whatsapp/SKILL.md +++ b/.claude/skills/add-whatsapp/SKILL.md @@ -200,7 +200,7 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group. - **type**: `whatsapp` - **terminology**: WhatsApp calls them "groups" and "chats." A "chat" is a 1:1 DM; a "group" has multiple members. -- **how-to-find-id**: DMs use `@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `sqlite3 data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`. +- **how-to-find-id**: DMs use `@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `pnpm exec tsx scripts/q.ts data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`. - **supports-threads**: no - **typical-use**: Interactive chat — direct messages or small groups - **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups. @@ -256,7 +256,7 @@ systemctl --user start nanoclaw 1. Auth exists: `test -f store/auth/creds.json` 2. Connected: `grep "Connected to WhatsApp" logs/nanoclaw.log | tail -1` -3. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"` +3. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"` 4. Service running: `systemctl --user status nanoclaw` ### "conflict" disconnection diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 128b8c3..e624162 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -57,7 +57,50 @@ Debug level shows: ## Common Issues -### 1. "Claude Code process exited with code 1" +### 1. "No adapter for channel type" / Messages silently lost (null platformMsgId) + +**Symptom:** The bot stops replying. `logs/nanoclaw.error.log` shows repeated: +``` +WARN No adapter for channel type channelType="telegram" +WARN No adapter for channel type channelType="signal" +``` +The main log shows "Message delivered" entries with `platformMsgId=undefined` — meaning the delivery poll ran, found no adapter, and permanently marked the message as delivered without sending it. + +**Root cause: two NanoClaw service instances running simultaneously.** + +When a second service instance (often `nanoclaw-v2-.service` running alongside `nanoclaw.service`) is active with a stale binary, it has no channel adapters registered. Its delivery poll races against the working instance and wins — permanently marking outbound messages as delivered without ever sending them. + +**Diagnosis:** +```bash +# Check for duplicate running instances +ps aux | grep 'nanoclaw/dist/index.js' | grep -v grep + +# Check which services are active +systemctl --user list-units 'nanoclaw*' --all + +# Confirm channel adapters registered by the current process +grep "Channel adapter started" logs/nanoclaw.log | tail -10 +``` + +**Fix:** +1. Identify which service has the correct binary and EnvironmentFile (the one showing `signal`, `telegram`, `cli` all started in the log). +2. Stop and disable the stale duplicate service: + ```bash + systemctl --user stop nanoclaw.service # or whichever is the old one + systemctl --user disable nanoclaw.service + ``` +3. If the remaining service unit is missing `EnvironmentFile`, add it: + ```bash + # Edit the service unit — add this line under [Service]: + # EnvironmentFile=/home/[user]/nanoclaw/.env + systemctl --user daemon-reload + systemctl --user restart nanoclaw-v2-.service + ``` +4. Verify only one instance runs: `ps aux | grep nanoclaw/dist/index.js | grep -v grep` + +**Note:** Messages that were marked delivered with a null `platform_message_id` cannot be automatically retried — they are permanently lost. The user must resend their message. + +### 2. "Claude Code process exited with code 1" **Check the container log file** in `groups/{folder}/logs/container-*.log` @@ -279,7 +322,7 @@ rm -rf data/sessions/ rm -rf data/sessions/{groupFolder}/.claude/ # Also clear the session ID from NanoClaw's tracking (stored in SQLite) -sqlite3 store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'" +pnpm exec tsx scripts/q.ts store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'" ``` To verify session resumption is working, check the logs for the same session ID across messages: diff --git a/.claude/skills/init-first-agent/SKILL.md b/.claude/skills/init-first-agent/SKILL.md index 6b110d3..67ab80b 100644 --- a/.claude/skills/init-first-agent/SKILL.md +++ b/.claude/skills/init-first-agent/SKILL.md @@ -54,7 +54,7 @@ Tell the user: Wait for the user's confirmation. Then look up the most recent DM messaging groups: ```bash -sqlite3 data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5" +pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5" ``` Show the top rows to the user and confirm which `platform_id` is theirs (usually the most recent). Record as `PLATFORM_ID`. If none appeared, check `logs/nanoclaw.log` for `unknown_sender` drops — the adapter might be rejecting inbound due to connection or permission issues. @@ -103,7 +103,7 @@ Wait for the user's reply. If they confirm receipt, the skill is done. If they say it didn't arrive, then diagnose using the DB directly (no waiting loops required — the message either delivered or it didn't): -- `sqlite3 data/v2-sessions//sessions//outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `` and `` with the values from the script's output. +- `pnpm exec tsx scripts/q.ts data/v2-sessions//sessions//outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `` and `` with the values from the script's output. - `grep -E 'Unauthorized channel destination|container.*exited|error' logs/nanoclaw.log | tail -20` — look for ACL rejections or container crashes. - `ls data/v2-sessions//sessions/*/outbound.db` — confirm the session exists. diff --git a/.claude/skills/init-onecli/SKILL.md b/.claude/skills/init-onecli/SKILL.md index b3d441f..ab64b73 100644 --- a/.claude/skills/init-onecli/SKILL.md +++ b/.claude/skills/init-onecli/SKILL.md @@ -259,6 +259,41 @@ Tell the user: - To manage secrets: `onecli secrets list`, or open ${ONECLI_URL} - To add rate limits or policies: `onecli rules create --help` +## Granting secrets to agents (safe merge) + +`set-secrets` **replaces** the agent's entire secret list — it never appends. Always read the current list first and merge before calling it. This pattern is canonical across all skills that assign secrets: + +```bash +AGENT_ID=$(onecli agents list | jq -r '.data[] | select(.identifier=="") | .id') +CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")') +MERGED=$(printf '%s' "$CURRENT," | tr ',' '\n' | sort -u | paste -sd ',' -) +onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED" +onecli agents secrets --id "$AGENT_ID" +``` + +- `` — the `agentGroupId` field in `groups//container.json` +- `` — the `id` from `onecli secrets list` +- Multiple new secrets: append them comma-separated before the `printf` step + +### git over HTTPS + +OneCLI's proxy injects credentials proactively — `injections_applied=1` appears in `docker logs onecli` even when git sends no auth header. However, OneCLI sets `SSL_CERT_FILE` for Node/Python/Deno but not `GIT_SSL_CAINFO`. Without it, git rejects the OneCLI MITM certificate. + +**Auth format matters**: GitHub's git smart HTTP protocol (`github.com`) requires `Basic` auth, not `Bearer`. GitHub's REST API (`api.github.com`) accepts `Bearer`. These must be configured as separate secrets with different formats — see `/add-github` for the full setup. + +If an agent uses `git` or `gh`, add to `data/v2-sessions//.claude-shared/settings.json`: + +```json +"GIT_SSL_CAINFO": "/tmp/onecli-combined-ca.pem", +"GIT_TERMINAL_PROMPT": "0", +"GIT_CONFIG_COUNT": "1", +"GIT_CONFIG_KEY_0": "credential.helper", +"GIT_CONFIG_VALUE_0": "", +"GH_TOKEN": "ghp_onecli_proxy_replaces_this" +``` + +**Debugging injection**: `docker logs onecli 2>&1 | grep "github.com"` shows every request with `injections_applied=N` and the HTTP status. If `injections_applied=1` but status is still 401, the injected credential value is wrong or uses the wrong auth format for that endpoint. + ## Troubleshooting **"OneCLI gateway not reachable" in logs:** The gateway isn't running. Check with `curl -sf ${ONECLI_URL}/health`. Start it with `onecli start` if needed. diff --git a/.claude/skills/manage-channels/SKILL.md b/.claude/skills/manage-channels/SKILL.md index 9d84d3d..21b3e19 100644 --- a/.claude/skills/manage-channels/SKILL.md +++ b/.claude/skills/manage-channels/SKILL.md @@ -11,7 +11,22 @@ Privilege is a **user-level** concept, not a channel-level one (see `src/db/user ## Assess Current State -Read the central DB (`data/v2.db`) — query `agent_groups`, `messaging_groups`, `messaging_group_agents`, `users`, and `user_roles` tables. Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports. +Read the central DB (`data/v2.db`) using these canonical queries (column names match the schema, not the CLI flags — the `register` command's `--assistant-name` is stored in `agent_groups.name`). + +Run each via the in-tree wrapper — the host setup deliberately ships no `sqlite3` CLI: + +```bash +pnpm exec tsx scripts/q.ts data/v2.db "" +``` + +```sql +SELECT id, name AS assistant_name, folder, agent_provider FROM agent_groups; +SELECT id, channel_type, platform_id, name, unknown_sender_policy FROM messaging_groups; +SELECT messaging_group_id, agent_group_id, session_mode, priority FROM messaging_group_agents; +SELECT user_id, role, agent_group_id FROM user_roles ORDER BY role='owner' DESC; +``` + +Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports. Categorize channels as: **wired** (has DB entities + messaging_group_agents row), **configured but unwired** (has credentials + barrel import, no DB entities), or **not configured**. diff --git a/CLAUDE.md b/CLAUDE.md index c17001b..92824fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,8 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f `data/v2.db` holds everything that isn't per-session: users, user_roles, agent_groups, messaging_groups, wiring, pending_approvals, user_dms, chat_sdk_* (for the Chat SDK bridge), schema_version. Migrations live at `src/db/migrations/`. +For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than the `sqlite3` CLI: `pnpm exec tsx scripts/q.ts ""`. The host setup intentionally avoids depending on the `sqlite3` binary (`setup/verify.ts:5`); the wrapper goes through the `better-sqlite3` dep that setup already installs and verifies. Default-output format matches `sqlite3 -list` (pipe-separated, no header) so existing skill text reads identically. + ## Key Files | File | Purpose | @@ -74,7 +76,7 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f | `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch | | `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) | | `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations | -| `container/skills/` | Container skills mounted into every agent session | +| `container/skills/` | Container skills mounted into every agent session (`onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`) | | `groups//` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) | | `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) | | `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). | @@ -98,7 +100,7 @@ A second tier (direct source-level self-edits via a draft/activate flow) is plan ## Secrets / Credentials / OneCLI -API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`. +API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. The container agent sees this via the `onecli-gateway` container skill (`container/skills/onecli-gateway/SKILL.md`), which teaches it how the proxy works, how to handle auth errors, and to never ask for raw credentials. Host-side wiring: `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`. ### Gotcha: auto-created agents start in `selective` secret mode @@ -142,7 +144,7 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono - **Channel/provider install skills** — copy the relevant module(s) in from the `channels` or `providers` branch, wire imports, install pinned deps (e.g. `/add-discord`, `/add-slack`, `/add-whatsapp`, `/add-opencode`). - **Utility skills** — ship code files alongside `SKILL.md` (e.g. `/claw`). - **Operational skills** — instruction-only workflows (`/setup`, `/debug`, `/customize`, `/init-first-agent`, `/manage-channels`, `/init-onecli`, `/update-nanoclaw`). -- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `welcome`, `self-customize`, `agent-browser`, `slack-formatting`). +- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`). | Skill | When to Use | |-------|-------------| diff --git a/README.md b/README.md index 69f9ea2..f364d27 100644 --- a/README.md +++ b/README.md @@ -215,3 +215,5 @@ See [CHANGELOG.md](CHANGELOG.md) for breaking changes, or the [full release hist ## License MIT + + diff --git a/container/Dockerfile b/container/Dockerfile index d6b654a..1dd2f88 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -91,7 +91,13 @@ RUN --mount=type=cache,target=/root/.bun/install/cache \ # the SDK fails at spawn time with "native binary not found". ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable +# Pin pnpm to match the host (package.json packageManager). pnpm 11 stopped +# honoring `only-built-dependencies[]=` in .npmrc for global installs, which +# silently skips claude-code's native-binary postinstall and agent-browser's +# bin chmod — the agent then crashes at runtime with "native binary not +# installed". Keep this in lockstep with package.json's `packageManager`. +ARG PNPM_VERSION=10.33.0 +RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate RUN --mount=type=cache,target=/root/.cache/pnpm \ echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \ diff --git a/container/agent-runner/src/compact-instructions.ts b/container/agent-runner/src/compact-instructions.ts new file mode 100644 index 0000000..b682061 --- /dev/null +++ b/container/agent-runner/src/compact-instructions.ts @@ -0,0 +1,34 @@ +/** + * PreCompact hook script — outputs custom compaction instructions to stdout. + * + * Claude Code captures the stdout of PreCompact shell hooks and passes it + * as `customInstructions` to the compaction prompt. This ensures the + * compaction summary preserves message routing context that the agent needs + * to correctly address responses. + * + * Invoked by the PreCompact hook in .claude-shared/settings.json: + * "command": "bun /app/src/compact-instructions.ts" + */ +import { getAllDestinations } from './destinations.js'; + +const destinations = getAllDestinations(); +const names = destinations.map((d) => d.name); + +const instructions = [ + 'Preserve the following in the compaction summary:', + '', + '1. For recent messages, keep the full XML structure including all attributes:', + ' - for chat messages', + ' - for scheduled tasks', + ' - for webhooks', + ' The message content can be summarized if long, but the XML tags and attributes must remain.', + '', + '2. Preserve the chronological message/reply sequence of recent exchanges.', + ' The agent needs to see: who said what, in what order, and from which destination.', + '', + '3. The `from` attribute identifies which destination sent the message.', + ' The agent MUST wrap all responses in ... blocks.', + ` Available destinations: ${names.length > 0 ? names.map((n) => `\`${n}\``).join(', ') : '(none)'}`, +]; + +console.log(instructions.join('\n')); diff --git a/container/agent-runner/src/current-batch.ts b/container/agent-runner/src/current-batch.ts new file mode 100644 index 0000000..b699c13 --- /dev/null +++ b/container/agent-runner/src/current-batch.ts @@ -0,0 +1,29 @@ +/** + * Per-batch context the poll loop publishes for downstream consumers + * (MCP tools, etc.) that don't sit on the poll-loop's call stack. + * + * Today the only field is `inReplyTo` — the id of the first inbound + * message in the batch the agent is currently processing. MCP tools like + * `send_message` and `send_file` read this and stamp it onto the outbound + * row so the host's a2a return-path routing can correlate replies back to + * the originating session. + * + * This is module-level state on purpose: the agent-runner is single-process + * and processes one batch at a time. Poll-loop calls `setCurrentInReplyTo` + * before invoking the provider and `clearCurrentInReplyTo` after the batch + * completes (or errors out). + */ +let currentInReplyTo: string | null = null; + +export function setCurrentInReplyTo(id: string | null): void { + currentInReplyTo = id; +} + +export function clearCurrentInReplyTo(): void { + currentInReplyTo = null; +} + +export function getCurrentInReplyTo(): string | null { + return currentInReplyTo; +} + diff --git a/container/agent-runner/src/destinations.test.ts b/container/agent-runner/src/destinations.test.ts new file mode 100644 index 0000000..14243f2 --- /dev/null +++ b/container/agent-runner/src/destinations.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +import { closeSessionDb, getInboundDb, initTestSessionDb } from './db/connection.js'; +import { buildSystemPromptAddendum } from './destinations.js'; + +beforeEach(() => { + initTestSessionDb(); +}); + +afterEach(() => { + closeSessionDb(); +}); + +function seedDestination(name: string, displayName: string, channelType: string, platformId: string): void { + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES (?, ?, 'channel', ?, ?, NULL)`, + ) + .run(name, displayName, channelType, platformId); +} + +describe('buildSystemPromptAddendum — multi-destination routing guidance', () => { + it('includes default-routing nudge when there are >1 destinations', () => { + seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us'); + seedDestination('whatsapp-mg-17780', 'whatsapp-mg-17780', 'whatsapp', 'phone-2@s.whatsapp.net'); + + const prompt = buildSystemPromptAddendum('Casa'); + + expect(prompt).toContain('Default routing'); + expect(prompt).toContain('from="name"'); + expect(prompt).toContain('`casa`'); + expect(prompt).toContain('`whatsapp-mg-17780`'); + }); + + it('requires explicit wrapping even for a single destination', () => { + seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us'); + + const prompt = buildSystemPromptAddendum('Casa'); + + expect(prompt).toContain('Every response must be wrapped'); + expect(prompt).toContain(''); + expect(prompt).toContain('`casa`'); + }); + + it('handles the no-destination case without crashing', () => { + const prompt = buildSystemPromptAddendum('Casa'); + + expect(prompt).toContain('no configured destinations'); + expect(prompt).not.toContain('Default routing'); + }); + + it('includes default-routing and wrapping instructions for single destination', () => { + seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us'); + + const prompt = buildSystemPromptAddendum('Casa'); + + expect(prompt).toContain('Every response must be wrapped'); + expect(prompt).toContain(''); + expect(prompt).toContain('Default routing'); + expect(prompt).toContain('`casa`'); + }); +}); diff --git a/container/agent-runner/src/destinations.ts b/container/agent-runner/src/destinations.ts index 013bd3b..6a29390 100644 --- a/container/agent-runner/src/destinations.ts +++ b/container/agent-runner/src/destinations.ts @@ -102,32 +102,28 @@ function buildDestinationsSection(): string { ].join('\n'); } - // Single-destination shortcut: the agent just writes its response normally. + const lines = ['## Sending messages', '']; if (all.length === 1) { const d = all[0]; const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : ''; - return [ - '## Sending messages', - '', - `Your messages are delivered to \`${d.name}\`${label}. Just write your response directly — no special wrapping needed.`, - '', - 'To mark something as scratchpad (logged but not sent), wrap it in `...`.', - '', - 'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool.', - ].join('\n'); - } - - const lines = ['## Sending messages', '', 'You can send messages to the following destinations:', '']; - for (const d of all) { - const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : ''; - lines.push(`- \`${d.name}\`${label}`); + lines.push(`Your destination is \`${d.name}\`${label}.`); + } else { + lines.push('You can send messages to the following destinations:', ''); + for (const d of all) { + const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : ''; + lines.push(`- \`${d.name}\`${label}`); + } } lines.push(''); - lines.push('To send a message, wrap it in a `...` block.'); + lines.push('**Every response must be wrapped** in a `...` block.'); lines.push('You can include multiple `` blocks in one response to send to multiple destinations.'); lines.push('Text outside of `` blocks is scratchpad — logged but not sent anywhere.'); lines.push('Use `...` to make scratchpad intent explicit.'); lines.push(''); + lines.push( + '**Default routing**: when replying to an incoming message, address the same destination the message came `from` — every inbound `` tag carries a `from="name"` attribute that names the origin destination. Only address a different destination when the request itself asks you to (e.g., "tell Laura that…").', + ); + lines.push(''); lines.push( 'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool with the `to` parameter set to a destination name.', ); diff --git a/container/agent-runner/src/formatter.ts b/container/agent-runner/src/formatter.ts index 348d5ab..236dbfb 100644 --- a/container/agent-runner/src/formatter.ts +++ b/container/agent-runner/src/formatter.ts @@ -177,40 +177,49 @@ function formatSingleChat(msg: MessageInRow): string { const replyPrefix = formatReplyContext(content.replyTo); const attachmentsSuffix = formatAttachments(content.attachments); - // Look up the destination name for the origin (reverse map lookup). - // If not found, fall back to a raw channel:platform_id marker so nothing - // gets silently dropped — this should only happen if the destination was - // removed between when the message was received and when it's being processed. - const fromDest = findByRouting(msg.channel_type, msg.platform_id); - const fromAttr = fromDest - ? ` from="${escapeXml(fromDest.name)}"` - : msg.channel_type || msg.platform_id - ? ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"` - : ''; + const fromAttr = originAttr(msg); return `${replyPrefix}${escapeXml(text)}${attachmentsSuffix}`; } +/** + * Build a ` from="destination_name"` attribute string from a message's routing + * fields. Shared by all formatters so the agent always knows where a message + * originated — critical for explicit addressing. + */ +function originAttr(msg: MessageInRow): string { + const fromDest = findByRouting(msg.channel_type, msg.platform_id); + if (fromDest) return ` from="${escapeXml(fromDest.name)}"`; + if (msg.channel_type || msg.platform_id) { + return ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"`; + } + return ''; +} + function formatTaskMessage(msg: MessageInRow): string { const content = parseContent(msg.content); - const parts = ['[SCHEDULED TASK]']; + const from = originAttr(msg); + const time = formatLocalTime(msg.timestamp, TIMEZONE); + const parts: string[] = []; if (content.scriptOutput) { - parts.push('', 'Script output:', JSON.stringify(content.scriptOutput, null, 2)); + parts.push('Script output:', JSON.stringify(content.scriptOutput, null, 2), ''); } - parts.push('', 'Instructions:', content.prompt || ''); - return parts.join('\n'); + parts.push('Instructions:', content.prompt || ''); + return `${parts.join('\n')}`; } function formatWebhookMessage(msg: MessageInRow): string { const content = parseContent(msg.content); const source = content.source || 'unknown'; const event = content.event || 'unknown'; - return `[WEBHOOK: ${source}/${event}]\n\n${JSON.stringify(content.payload || content, null, 2)}`; + const from = originAttr(msg); + return `${JSON.stringify(content.payload || content, null, 2)}`; } function formatSystemMessage(msg: MessageInRow): string { const content = parseContent(msg.content); - return `[SYSTEM RESPONSE]\n\nAction: ${content.action || 'unknown'}\nStatus: ${content.status || 'unknown'}\nResult: ${JSON.stringify(content.result || null)}`; + const from = originAttr(msg); + return `${JSON.stringify(content.result || null)}`; } /** diff --git a/container/agent-runner/src/integration.test.ts b/container/agent-runner/src/integration.test.ts index 3447c38..7396cfe 100644 --- a/container/agent-runner/src/integration.test.ts +++ b/container/agent-runner/src/integration.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js'; import { getUndeliveredMessages } from './db/messages-out.js'; import { getPendingMessages } from './db/messages-in.js'; +import { getContinuation, setContinuation } from './db/session-state.js'; import { MockProvider } from './providers/mock.js'; import { runPollLoop } from './poll-loop.js'; @@ -74,6 +75,163 @@ describe('poll loop integration', () => { await loopPromise.catch(() => {}); }); + it('should resolve thread_id per-destination, not from global routing', async () => { + // Seed a second destination + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES ('slack-test', 'Slack Test', 'channel', 'slack', 'chan-2', NULL)`, + ) + .run(); + + // Insert messages from each destination with distinct thread IDs + insertMessage('m-discord', { sender: 'Alice', text: 'from discord' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'discord-thread-1' }); + insertMessage('m-slack', { sender: 'Bob', text: 'from slack' }, { platformId: 'chan-2', channelType: 'slack', threadId: 'slack-thread-99' }); + + // Agent replies to both destinations + const provider = new MockProvider({}, () => + 'reply-dreply-s', + ); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length >= 2, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + const discordOut = out.find((m) => m.platform_id === 'chan-1'); + const slackOut = out.find((m) => m.platform_id === 'chan-2'); + + expect(discordOut).toBeDefined(); + expect(discordOut!.thread_id).toBe('discord-thread-1'); + expect(discordOut!.in_reply_to).toBe('m-discord'); + + expect(slackOut).toBeDefined(); + expect(slackOut!.thread_id).toBe('slack-thread-99'); + expect(slackOut!.in_reply_to).toBe('m-slack'); + + await loopPromise.catch(() => {}); + }); + + it('bare text produces no outbound messages (scratchpad only)', async () => { + insertMessage('m1', { sender: 'Alice', text: 'hello' }, { platformId: 'chan-1', channelType: 'discord' }); + + // Agent responds with bare text — no wrapping + const provider = new MockProvider({}, () => 'I am thinking about this...'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + // Wait long enough for the poll loop to process + await sleep(1000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(0); + + await loopPromise.catch(() => {}); + }); + + it('unknown destination is dropped, valid destination is sent', async () => { + insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new MockProvider( + {}, + () => 'droppeddelivered', + ); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + // Only the valid destination should produce output + expect(out).toHaveLength(1); + expect(JSON.parse(out[0].content).text).toBe('delivered'); + expect(out[0].platform_id).toBe('chan-1'); + + await loopPromise.catch(() => {}); + }); + + it('multiple blocks each produce an outbound message', async () => { + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES ('slack-test', 'Slack Test', 'channel', 'slack', 'chan-2', NULL)`, + ) + .run(); + + insertMessage('m1', { sender: 'Alice', text: 'broadcast' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new MockProvider( + {}, + () => 'for discordfor slack', + ); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length >= 2, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(2); + const discord = out.find((m) => m.platform_id === 'chan-1'); + const slack = out.find((m) => m.platform_id === 'chan-2'); + expect(discord).toBeDefined(); + expect(JSON.parse(discord!.content).text).toBe('for discord'); + expect(slack).toBeDefined(); + expect(JSON.parse(slack!.content).text).toBe('for slack'); + + await loopPromise.catch(() => {}); + }); + + it('sends null thread_id when no prior inbound from destination', async () => { + // Seed a second destination that has NO inbound messages + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES ('slack-new', 'Slack New', 'channel', 'slack', 'chan-new', NULL)`, + ) + .run(); + + // Only insert a message from discord — slack-new has never sent anything + insertMessage('m1', { sender: 'Alice', text: 'tell slack' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'discord-thread' }); + + const provider = new MockProvider({}, () => 'hello slack'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(out[0].platform_id).toBe('chan-new'); + expect(out[0].thread_id).toBeNull(); + + await loopPromise.catch(() => {}); + }); + + it('resolves most recent thread_id when destination has multiple inbound messages', async () => { + // Two messages from same destination, different threads + insertMessage('m-old', { sender: 'Alice', text: 'old' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-old' }); + insertMessage('m-new', { sender: 'Alice', text: 'new' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-new' }); + + const provider = new MockProvider({}, () => 'reply'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(out[0].thread_id).toBe('thread-new'); + expect(out[0].in_reply_to).toBe('m-new'); + + await loopPromise.catch(() => {}); + }); + it('should process messages arriving after loop starts', async () => { const provider = new MockProvider({}, () => 'Processed'); const controller = new AbortController(); @@ -91,8 +249,161 @@ describe('poll loop integration', () => { await loopPromise.catch(() => {}); }); + + it('internal tags between message blocks are stripped from scratchpad', async () => { + insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new MockProvider( + {}, + () => 'thinking about this...answerdone thinking', + ); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(JSON.parse(out[0].content).text).toBe('answer'); + + await loopPromise.catch(() => {}); + }); + + it('handles mixed task + chat batch with correct origin metadata', async () => { + // Seed destination for routing lookup + insertMessage('m-chat', { sender: 'Alice', text: 'check this' }, { platformId: 'chan-1', channelType: 'discord' }); + // Task with same routing — simulates a scheduled task in a channel session + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content) + VALUES ('t-task', 'task', datetime('now'), 'pending', 'chan-1', 'discord', ?)`, + ) + .run(JSON.stringify({ prompt: 'daily check' })); + + const provider = new MockProvider({}, () => 'done'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(out[0].platform_id).toBe('chan-1'); + + await loopPromise.catch(() => {}); + }); + + it('should inject destination reminder after a compacted event', async () => { + // Two destinations — required for the reminder to fire (single-destination + // groups have a fallback path that works without wrapping). + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES ('discord-second', 'Discord Second', 'channel', 'discord', 'chan-2', NULL)`, + ) + .run(); + + insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new CompactingProvider(); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500); + + await waitFor(() => getUndeliveredMessages().length > 0, 2500); + controller.abort(); + + expect(provider.pushes.length).toBeGreaterThanOrEqual(1); + const reminder = provider.pushes.find((p) => p.includes('Context was just compacted')); + expect(reminder).toBeDefined(); + expect(reminder).toContain('2 destinations'); + expect(reminder).toContain('discord-test'); + expect(reminder).toContain('discord-second'); + expect(reminder).toContain(''); + + await loopPromise.catch(() => {}); + }); + + it('should NOT inject destination reminder with a single destination', async () => { + insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new CompactingProvider(); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500); + + await waitFor(() => getUndeliveredMessages().length > 0, 2500); + controller.abort(); + + // Only the original prompt push (if any) — no reminder, since beforeEach + // seeds exactly one destination. + const reminders = provider.pushes.filter((p) => p.includes('Context was just compacted')); + expect(reminders).toHaveLength(0); + + await loopPromise.catch(() => {}); + }); }); +/** + * Provider that emits a single compacted event mid-stream, then returns a + * result. Captures every push() call so tests can assert on the injected + * reminder content. + */ +class CompactingProvider { + readonly supportsNativeSlashCommands = false; + readonly pushes: string[] = []; + + isSessionInvalid(): boolean { + return false; + } + + query(_input: { prompt: string; cwd: string }) { + const pushes = this.pushes; + let ended = false; + let aborted = false; + let resolveWaiter: (() => void) | null = null; + + async function* events() { + yield { type: 'activity' as const }; + yield { type: 'init' as const, continuation: 'compaction-test-session' }; + yield { type: 'activity' as const }; + yield { type: 'compacted' as const, text: 'Context compacted (50,000 tokens compacted).' }; + + // Wait for poll-loop to push the reminder (or end / abort) + await new Promise((resolve) => { + resolveWaiter = resolve; + // Belt-and-braces: don't hang forever if the reminder never arrives + setTimeout(resolve, 200); + }); + + yield { type: 'activity' as const }; + yield { type: 'result' as const, text: 'ack' }; + while (!ended && !aborted) { + await new Promise((resolve) => { + resolveWaiter = resolve; + setTimeout(resolve, 50); + }); + } + } + + return { + push(message: string) { + pushes.push(message); + resolveWaiter?.(); + }, + end() { + ended = true; + resolveWaiter?.(); + }, + abort() { + aborted = true; + resolveWaiter?.(); + }, + events: events(), + }; + } +} + // Helper: run poll loop until aborted or timeout async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise { return Promise.race([ @@ -119,3 +430,142 @@ async function waitFor(condition: () => boolean, timeoutMs: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + +describe('poll loop — provider error recovery', () => { + it('writes error to outbound and continues loop on provider throw', async () => { + insertMessage('m1', { sender: 'Alice', text: 'trigger error' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new ThrowingProvider('API rate limit exceeded'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(JSON.parse(out[0].content).text).toContain('Error:'); + expect(JSON.parse(out[0].content).text).toContain('API rate limit exceeded'); + + // Input message should be marked completed despite the error + const pending = getPendingMessages(); + expect(pending).toHaveLength(0); + + await loopPromise.catch(() => {}); + }); +}); + +describe('poll loop — stale session recovery', () => { + it('clears continuation when provider reports session invalid', async () => { + // Pre-seed a continuation so the local variable in runPollLoop is set. + // Without this, the `if (continuation && isSessionInvalid)` check skips. + setContinuation('mock', 'pre-existing-session'); + + insertMessage('m1', { sender: 'Alice', text: 'stale session' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new InvalidSessionProvider(); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + // Error was written to outbound + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(JSON.parse(out[0].content).text).toContain('Error:'); + + // Continuation was cleared (isSessionInvalid returned true) + expect(getContinuation('mock')).toBeUndefined(); + + await loopPromise.catch(() => {}); + }); +}); + +describe('poll loop — /clear command', () => { + it('clears session, writes confirmation, skips query', async () => { + // Seed a continuation so we can verify it gets cleared + setContinuation('mock', 'existing-session-id'); + expect(getContinuation('mock')).toBe('existing-session-id'); + + // Insert a /clear command + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content) + VALUES ('m-clear', 'chat', datetime('now'), 'pending', 'chan-1', 'discord', ?)`, + ) + .run(JSON.stringify({ text: '/clear' })); + + const provider = new MockProvider({}, () => 'should not run'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(JSON.parse(out[0].content).text).toBe('Session cleared.'); + + // Continuation was cleared + expect(getContinuation('mock')).toBeUndefined(); + + // Command message was completed + const pending = getPendingMessages(); + expect(pending).toHaveLength(0); + + await loopPromise.catch(() => {}); + }); +}); + +/** + * Provider that throws on every query, simulating API failures. + */ +class ThrowingProvider { + readonly supportsNativeSlashCommands = false; + private errorMessage: string; + + constructor(errorMessage: string) { + this.errorMessage = errorMessage; + } + + isSessionInvalid(): boolean { + return false; + } + + query(_input: { prompt: string; cwd: string }) { + const errorMessage = this.errorMessage; + return { + push() {}, + end() {}, + abort() {}, + events: (async function* () { + throw new Error(errorMessage); + })(), + }; + } +} + +/** + * Provider that throws with an error that triggers isSessionInvalid. + * First emits an init event (setting continuation), then throws. + */ +class InvalidSessionProvider { + readonly supportsNativeSlashCommands = false; + + isSessionInvalid(): boolean { + return true; + } + + query(_input: { prompt: string; cwd: string }) { + return { + push() {}, + end() {}, + abort() {}, + events: (async function* () { + yield { type: 'init' as const, continuation: 'doomed-session' }; + throw new Error('session not found'); + })(), + }; + } +} diff --git a/container/agent-runner/src/mcp-tools/core.test.ts b/container/agent-runner/src/mcp-tools/core.test.ts new file mode 100644 index 0000000..4cef950 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/core.test.ts @@ -0,0 +1,50 @@ +/** + * Tests for the core MCP tools' interaction with the per-batch routing + * context. The agent-runner sets a current `inReplyTo` at the top of each + * batch in poll-loop, and outbound writes from MCP tools (send_message, + * send_file) must pick it up so a2a return-path routing on the host can + * correlate replies back to the originating session. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; + +import { initTestSessionDb, closeSessionDb, getInboundDb } from '../db/connection.js'; +import { getUndeliveredMessages } from '../db/messages-out.js'; +import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js'; +import { sendMessage } from './core.js'; + +beforeEach(() => { + initTestSessionDb(); + // Seed a peer agent destination + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES ('peer', 'Peer', 'agent', NULL, NULL, 'ag-peer')`, + ) + .run(); +}); + +afterEach(() => { + clearCurrentInReplyTo(); + closeSessionDb(); +}); + +describe('send_message MCP tool — in_reply_to plumbing', () => { + it('stamps current batch in_reply_to on outbound rows', async () => { + setCurrentInReplyTo('inbound-msg-1'); + + await sendMessage.handler({ to: 'peer', text: 'hello' }); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(out[0].in_reply_to).toBe('inbound-msg-1'); + }); + + it('writes null when no batch is active', async () => { + // No setCurrentInReplyTo before this call — simulates ad-hoc / out-of-batch invocation. + await sendMessage.handler({ to: 'peer', text: 'hello' }); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(out[0].in_reply_to).toBeNull(); + }); +}); diff --git a/container/agent-runner/src/mcp-tools/core.ts b/container/agent-runner/src/mcp-tools/core.ts index bf89ef8..48f87d5 100644 --- a/container/agent-runner/src/mcp-tools/core.ts +++ b/container/agent-runner/src/mcp-tools/core.ts @@ -9,6 +9,7 @@ import fs from 'fs'; import path from 'path'; +import { getCurrentInReplyTo } from '../current-batch.js'; import { findByName, getAllDestinations } from '../destinations.js'; import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js'; import { getSessionRouting } from '../db/session-routing.js'; @@ -50,9 +51,7 @@ function destinationList(): string { */ function resolveRouting( to: string | undefined, -): - | { channel_type: string; platform_id: string; thread_id: string | null; resolvedName: string } - | { error: string } { +): { channel_type: string; platform_id: string; thread_id: string | null; resolvedName: string } | { error: string } { if (!to) { // Default: reply to whatever thread/channel this session is bound to. const session = getSessionRouting(); @@ -82,9 +81,7 @@ function resolveRouting( // preserve the thread_id so replies land in the correct thread. const session = getSessionRouting(); const threadId = - session.channel_type === dest.channelType && session.platform_id === dest.platformId - ? session.thread_id - : null; + session.channel_type === dest.channelType && session.platform_id === dest.platformId ? session.thread_id : null; return { channel_type: dest.channelType!, platform_id: dest.platformId!, @@ -98,12 +95,14 @@ function resolveRouting( export const sendMessage: McpToolDefinition = { tool: { name: 'send_message', - description: - 'Send a message to a named destination. If you have only one destination, you can omit `to`.', + description: 'Send a message to a named destination. If you have only one destination, you can omit `to`.', inputSchema: { type: 'object' as const, properties: { - to: { type: 'string', description: 'Destination name (e.g., "family", "worker-1"). Optional if you have only one destination.' }, + to: { + type: 'string', + description: 'Destination name (e.g., "family", "worker-1"). Optional if you have only one destination.', + }, text: { type: 'string', description: 'Message content' }, }, required: ['text'], @@ -119,6 +118,7 @@ export const sendMessage: McpToolDefinition = { const id = generateId(); const seq = writeMessageOut({ id, + in_reply_to: getCurrentInReplyTo(), kind: 'chat', platform_id: routing.platform_id, channel_type: routing.channel_type, @@ -165,6 +165,7 @@ export const sendFile: McpToolDefinition = { writeMessageOut({ id, + in_reply_to: getCurrentInReplyTo(), kind: 'chat', platform_id: routing.platform_id, channel_type: routing.channel_type, diff --git a/container/agent-runner/src/poll-loop.test.ts b/container/agent-runner/src/poll-loop.test.ts index 356108f..82f9f75 100644 --- a/container/agent-runner/src/poll-loop.test.ts +++ b/container/agent-runner/src/poll-loop.test.ts @@ -47,7 +47,7 @@ describe('formatter', () => { insertMessage('m1', 'task', { prompt: 'Review open PRs' }); const messages = getPendingMessages(); const prompt = formatMessages(messages); - expect(prompt).toContain('[SCHEDULED TASK]'); + expect(prompt).toContain(' { insertMessage('m1', 'webhook', { source: 'github', event: 'push', payload: { ref: 'main' } }); const messages = getPendingMessages(); const prompt = formatMessages(messages); - expect(prompt).toContain('[WEBHOOK: github/push]'); + expect(prompt).toContain(' { insertMessage('m1', 'system', { action: 'register_group', status: 'success', result: { id: 'ag-1' } }); const messages = getPendingMessages(); const prompt = formatMessages(messages); - expect(prompt).toContain('[SYSTEM RESPONSE]'); - expect(prompt).toContain('register_group'); + expect(prompt).toContain(' { @@ -72,7 +74,7 @@ describe('formatter', () => { const messages = getPendingMessages(); const prompt = formatMessages(messages); expect(prompt).toContain('sender="John"'); - expect(prompt).toContain('[SYSTEM RESPONSE]'); + expect(prompt).toContain(' { @@ -147,6 +149,76 @@ describe('routing', () => { }); }); +describe('origin metadata (from= attribute)', () => { + function seedDestination(name: string, channelType: string, platformId: string): void { + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES (?, ?, 'channel', ?, ?, NULL)`, + ) + .run(name, name, channelType, platformId); + } + + function insertWithRouting(id: string, kind: string, content: object, channelType: string | null, platformId: string | null): void { + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content) + VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`, + ) + .run(id, kind, platformId, channelType, JSON.stringify(content)); + } + + it('chat message includes from= when destination matches', () => { + seedDestination('discord-main', 'discord', 'chan-1'); + insertWithRouting('m1', 'chat', { sender: 'Alice', text: 'hi' }, 'discord', 'chan-1'); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).toContain('from="discord-main"'); + }); + + it('chat message falls back to raw routing when no destination matches', () => { + insertWithRouting('m1', 'chat', { sender: 'Alice', text: 'hi' }, 'telegram', 'chat-999'); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).toContain('from="unknown:telegram:chat-999"'); + }); + + it('chat message omits from= when routing is null', () => { + insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' }); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).not.toContain('from='); + }); + + it('task message includes from= when destination matches', () => { + seedDestination('slack-ops', 'slack', 'C-OPS'); + insertWithRouting('t1', 'task', { prompt: 'check status' }, 'slack', 'C-OPS'); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).toContain(' { + insertMessage('t1', 'task', { prompt: 'check status' }); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).toContain(' { + seedDestination('github-ch', 'github', 'repo-1'); + insertWithRouting('w1', 'webhook', { source: 'github', event: 'push', payload: {} }, 'github', 'repo-1'); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).toContain(' { + seedDestination('discord-main', 'discord', 'chan-1'); + insertWithRouting('s1', 'system', { action: 'test', status: 'ok', result: null }, 'discord', 'chan-1'); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).toContain(' { it('should produce init + result events', async () => { const provider = new MockProvider({}, (prompt) => `Echo: ${prompt}`); diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index e825184..e0ac722 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -1,13 +1,18 @@ import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js'; import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js'; import { writeMessageOut } from './db/messages-out.js'; -import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; +import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; +import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js'; +import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js'; import { - clearContinuation, - migrateLegacyContinuation, - setContinuation, -} from './db/session-state.js'; -import { formatMessages, extractRouting, categorizeMessage, isClearCommand, isRunnerCommand, stripInternalTags, type RoutingContext } from './formatter.js'; + formatMessages, + extractRouting, + categorizeMessage, + isClearCommand, + isRunnerCommand, + stripInternalTags, + type RoutingContext, +} from './formatter.js'; import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js'; const POLL_INTERVAL_MS = 1000; @@ -170,6 +175,9 @@ export async function runPollLoop(config: PollLoopConfig): Promise { // Process the query while concurrently polling for new messages const skippedSet = new Set(skipped); const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id)); + // Publish the batch's in_reply_to so MCP tools (send_message, send_file) + // can stamp it on outbound rows — needed for a2a return-path routing. + setCurrentInReplyTo(routing.inReplyTo); try { const result = await processQuery(query, routing, processingIds, config.providerName); if (result.continuation && result.continuation !== continuation) { @@ -198,6 +206,8 @@ export async function runPollLoop(config: PollLoopConfig): Promise { thread_id: routing.threadId, content: JSON.stringify({ text: `Error: ${errMsg}` }), }); + } finally { + clearCurrentInReplyTo(); } // Ensure completed even if processQuery ended without a result event @@ -366,6 +376,23 @@ async function processQuery( if (event.text) { dispatchResultText(event.text, routing); } + } else if (event.type === 'compacted') { + // The SDK auto-compacted the conversation. After compaction the + // model often drops the learned `` wrapping + // discipline (the destinations are still in the system prompt, + // but the behavioral pattern is summarized away). Inject a + // reminder back into the live query so the next turn re-anchors + // on the destination model. Only do this when there's >1 + // destination — single-destination groups have a fallback that + // works without wrapping. See qwibitai/nanoclaw#2325. + const destinations = getAllDestinations(); + if (destinations.length > 1) { + const names = destinations.map((d) => d.name).join(', '); + query.push( + `[system] Context was just compacted. Reminder: you have ${destinations.length} destinations (${names}). ` + + `Use blocks to address them. Bare text goes to the scratchpad fallback only.`, + ); + } } } } finally { @@ -385,25 +412,26 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void { log(`Result: ${event.text ? event.text.slice(0, 200) : '(empty)'}`); break; case 'error': - log(`Error: ${event.message} (retryable: ${event.retryable}${event.classification ? `, ${event.classification}` : ''})`); + log( + `Error: ${event.message} (retryable: ${event.retryable}${event.classification ? `, ${event.classification}` : ''})`, + ); break; case 'progress': log(`Progress: ${event.message}`); break; + case 'compacted': + log(`Compacted: ${event.text}`); + break; } } /** * Parse the agent's final text for ... blocks * and dispatch each one to its resolved destination. Text outside of blocks - * (including ...) is normally scratchpad — logged but - * not sent. + * (including ...) is scratchpad — logged but not sent. * - * Single-destination shortcut: if the agent has exactly one configured - * destination AND the output contains zero blocks, the entire - * cleaned text (with tags stripped) is sent to that destination. - * This preserves the simple case of one user on one channel — the agent - * doesn't need to know about wrapping syntax at all. + * The agent must always wrap output in ... + * blocks, even with a single destination. Bare text is scratchpad only. */ function dispatchResultText(text: string, routing: RoutingContext): void { const MESSAGE_RE = /([\s\S]*?)<\/message>/g; @@ -436,30 +464,6 @@ function dispatchResultText(text: string, routing: RoutingContext): void { const scratchpad = stripInternalTags(scratchpadParts.join('')); - // Single-destination shortcut: the agent wrote plain text — send to - // the session's originating channel (from session_routing) if available, - // otherwise fall back to the single destination. - if (sent === 0 && scratchpad) { - if (routing.channelType && routing.platformId) { - // Reply to the channel/thread the message came from - writeMessageOut({ - id: generateId(), - in_reply_to: routing.inReplyTo, - kind: 'chat', - platform_id: routing.platformId, - channel_type: routing.channelType, - thread_id: routing.threadId, - content: JSON.stringify({ text: scratchpad }), - }); - return; - } - const all = getAllDestinations(); - if (all.length === 1) { - sendToDestination(all[0], scratchpad, routing); - return; - } - } - if (scratchpad) { log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`); } @@ -472,20 +476,46 @@ function dispatchResultText(text: string, routing: RoutingContext): void { function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void { const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!; const channelType = dest.type === 'channel' ? dest.channelType! : 'agent'; - // Inherit thread_id from the inbound routing context so replies land in the - // same thread the conversation is in. For non-threaded adapters the router - // strips thread_id at ingest, so this will already be null. + // Resolve thread_id per-destination from the most recent inbound message + // that came from this same channel+platform. In agent-shared sessions, + // different destinations have different thread contexts — using a single + // routing.threadId would stamp one channel's thread onto another. + const destRouting = resolveDestinationThread(channelType, platformId); writeMessageOut({ id: generateId(), - in_reply_to: routing.inReplyTo, + in_reply_to: destRouting?.inReplyTo ?? routing.inReplyTo, kind: 'chat', platform_id: platformId, channel_type: channelType, - thread_id: routing.threadId, + thread_id: destRouting?.threadId ?? null, content: JSON.stringify({ text: body }), }); } +/** + * Find the thread_id and message id from the most recent inbound message + * matching the given channel+platform. Returns null if no match found. + */ +function resolveDestinationThread( + channelType: string, + platformId: string, +): { threadId: string | null; inReplyTo: string | null } | null { + try { + const db = getInboundDb(); + const row = db + .prepare( + `SELECT thread_id, id FROM messages_in + WHERE channel_type = ? AND platform_id = ? + ORDER BY seq DESC LIMIT 1`, + ) + .get(channelType, platformId) as { thread_id: string | null; id: string } | undefined; + if (row) return { threadId: row.thread_id, inReplyTo: row.id }; + } catch (err) { + log(`resolveDestinationThread error: ${err instanceof Error ? err.message : String(err)}`); + } + return null; +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 6c30cc2..6850e51 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -329,7 +329,7 @@ export class ClaudeProvider implements AgentProvider { } else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') { const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata; const detail = meta?.pre_tokens ? ` (${meta.pre_tokens.toLocaleString()} tokens compacted)` : ''; - yield { type: 'result', text: `Context compacted${detail}.` }; + yield { type: 'compacted', text: `Context compacted${detail}.` }; } else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') { const tn = message as { summary?: string }; yield { type: 'progress', message: tn.summary || 'Task notification' }; diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index 55ab919..b4b1fc8 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -79,4 +79,12 @@ export type ProviderEvent = * event (tool call, thinking, partial message, anything) so the * poll-loop's idle timer stays honest during long tool runs. */ - | { type: 'activity' }; + | { type: 'activity' } + /** + * The provider's underlying SDK auto-compacted the conversation context. + * The poll-loop reacts by injecting a destination reminder back into + * the live query so the agent doesn't drop `` wrapping + * after compaction. Distinct from `result` so it doesn't mark the turn + * completed or get dispatched as a chat message. See qwibitai/nanoclaw#2325. + */ + | { type: 'compacted'; text: string }; diff --git a/container/skills/onecli-gateway/SKILL.md b/container/skills/onecli-gateway/SKILL.md new file mode 100644 index 0000000..2e77d45 --- /dev/null +++ b/container/skills/onecli-gateway/SKILL.md @@ -0,0 +1,85 @@ +--- +name: onecli-gateway +description: >- + OneCLI Gateway: transparent HTTPS proxy that injects stored credentials + into outbound calls. You MUST use this skill when the user asks you to + read emails, check calendar, access GitHub repos, create issues, check + Stripe payments, or interact with ANY external service or API. Do NOT + use browser extensions or OAuth CLI tools. Make HTTP requests directly; + the gateway injects credentials automatically. +compatibility: Requires HTTPS_PROXY set in environment (automatic when launched via `onecli run`) +metadata: + author: onecli + version: "0.5.0" +--- + +# OneCLI Gateway + +Your outbound HTTPS traffic is transparently proxied through the OneCLI +gateway, which injects stored credentials at the proxy boundary. You never +see or handle credential values directly. + +## How to Access External Services + +You have direct HTTP access to external APIs. OAuth apps (Gmail, GitHub, +Google Calendar, Google Drive, etc.) and API key services are all available +through the gateway. Just make the request directly; the gateway injects +credentials if the app is connected. If not, it returns an error with a +connect URL you can present to the user. + +## Making Requests + +Call the real API URL. The gateway intercepts the request and injects +credentials automatically. + +```bash +curl -s "https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=5" +curl -s "https://api.github.com/user/repos?per_page=10" +curl -s "https://api.stripe.com/v1/charges?limit=5" +``` + +Standard HTTP clients (curl, fetch, requests, axios, Go net/http, git) all +honor the `HTTPS_PROXY` environment variable automatically. You do not need +to set any auth headers. + +## Credential Stubs for MCP Servers + +Some MCP servers need local credential files to start. Stubs for connected +apps are pre-written automatically. Files containing `"onecli-managed"` +values are managed by OneCLI — do NOT modify or delete them. + +If an MCP server won't start due to missing credentials, create stubs +**before** starting it. Use `"onecli-managed"` as the placeholder for all +secret values, with file permissions `0600`. See the guide at: +https://www.onecli.sh/docs/guides/credential-stubs/general-app + +## When a Request Fails + +If you get a 401, 403, or a gateway error (e.g., `app_not_connected`): + +**Step 1 — Show the user a connect link.** Use the `connect_url` from the +error response: + +> To connect [service], open this link: +> [connect_url from the error response] + +If there is no `connect_url` in the error, tell the user to open the +OneCLI dashboard and connect the service there. + +**Step 2 — Retry after the user connects.** Let the user know you will +retry once they have connected. When they confirm, retry the original +request. If the retry still fails, ask if they need help with the setup. + +## Rules + +- **Never** say "I don't have access to X" without first making the HTTP + request through the proxy. +- **Never** use browser extensions, gcloud, or manual auth flows. The + gateway handles credentials for you. +- **Never** ask the user for API keys or tokens directly. Direct them to + connect the service in the OneCLI dashboard. +- **Never** suggest the user open Gmail/Calendar/GitHub in their browser + when they ask you to read or interact with those services. You have API + access. Use it. +- If the gateway returns a policy error (403 with a JSON body), respect + the block. Do not retry or circumvent it. diff --git a/container/skills/onecli-gateway/instructions.md b/container/skills/onecli-gateway/instructions.md new file mode 100644 index 0000000..26d347a --- /dev/null +++ b/container/skills/onecli-gateway/instructions.md @@ -0,0 +1,7 @@ +# Credentials & External Services + +Your HTTP requests go through the OneCLI proxy, which injects real credentials automatically. Just call any API directly (Gmail, GitHub, Slack, etc.) — the proxy adds auth before it reaches the service. + +Use any method: curl, Python, a CLI tool, whatever fits. If a tool checks for credentials locally, pass any placeholder value — the proxy replaces it with real credentials at request time. + +If you get a `401`/`403`/`app_not_connected`, run `/onecli-gateway` for the full error-handling flow. Never ask the user for API keys or tokens — if credentials are missing, the fix is connecting the service in OneCLI. diff --git a/migrate-v2.sh b/migrate-v2.sh index 2325edd..46a6670 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -242,8 +242,12 @@ fi V1_DB="$V1_PATH/store/messages.db" -# Quick schema check — make sure the tables we need exist -TABLES=$(sqlite3 "$V1_DB" ".tables" 2>/dev/null || true) +# Quick schema check — make sure the tables we need exist. +# Uses the in-tree wrapper instead of the sqlite3 CLI: setup.sh (run via +# phase 0a above) installs Node + better-sqlite3 but NOT the sqlite3 CLI, +# and #2191 documented how a missing CLI here used to surface as a +# misleading "registered_groups missing" abort. +TABLES=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT name FROM sqlite_master WHERE type='table'" 2>/dev/null || true) if echo "$TABLES" | grep -q "registered_groups"; then step_ok "v1 database has registered_groups" @@ -253,8 +257,8 @@ else fi # Show what we found -GROUP_COUNT=$(sqlite3 "$V1_DB" "SELECT COUNT(*) FROM registered_groups" 2>/dev/null || echo 0) -TASK_COUNT=$(sqlite3 "$V1_DB" "SELECT COUNT(*) FROM scheduled_tasks WHERE status='active'" 2>/dev/null || echo 0) +GROUP_COUNT=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT COUNT(*) FROM registered_groups" 2>/dev/null || echo 0) +TASK_COUNT=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT COUNT(*) FROM scheduled_tasks WHERE status='active'" 2>/dev/null || echo 0) ENV_KEYS=0 if [ -f "$V1_PATH/.env" ]; then ENV_KEYS=$(grep -c '=' "$V1_PATH/.env" 2>/dev/null || echo 0) @@ -450,7 +454,7 @@ ONECLI_OK=false ONECLI_URL_FROM_ENV=$(grep '^ONECLI_URL=' .env 2>/dev/null | head -1 | sed 's/^ONECLI_URL=//') ONECLI_URL_CHECK="${ONECLI_URL_FROM_ENV:-http://127.0.0.1:10254}" -if curl -sf "${ONECLI_URL_CHECK}/health" >/dev/null 2>&1; then +if curl -sf "${ONECLI_URL_CHECK}/api/health" >/dev/null 2>&1; then step_ok "OneCLI running at $(dim "$ONECLI_URL_CHECK")" ONECLI_OK=true log "OneCLI: running at $ONECLI_URL_CHECK" diff --git a/package.json b/package.json index 96f4ae9..77afaaf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.32", + "version": "2.0.45", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", @@ -26,7 +26,7 @@ "dependencies": { "@clack/core": "^1.2.0", "@clack/prompts": "^1.2.0", - "@onecli-sh/sdk": "^0.3.1", + "@onecli-sh/sdk": "^0.5.0", "better-sqlite3": "11.10.0", "chat": "^4.24.0", "cron-parser": "5.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f74033..902b6ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^1.2.0 version: 1.2.0 '@onecli-sh/sdk': - specifier: ^0.3.1 - version: 0.3.1 + specifier: ^0.5.0 + version: 0.5.0 better-sqlite3: specifier: 11.10.0 version: 11.10.0 @@ -303,8 +303,8 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@onecli-sh/sdk@0.3.1': - resolution: {integrity: sha512-oMSa4DUCVS52vec41nFOg3XdCBTbMVEZdCFCsaUd9sRXVorCPWd3VyZq4giXsmk4g09DA/zLjsnrY7l6G94Ulg==} + '@onecli-sh/sdk@0.5.0': + resolution: {integrity: sha512-oe5Yx9o98v6N1PgzcCR7nULHHqcqKWNJIDOHGOSNX+l20mLlZpFUqfKPeFmsojBNRQMoqbvZQKUlFMp6gVuYBA==} engines: {node: '>=20'} '@oxc-project/types@0.124.0': @@ -1665,7 +1665,7 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@onecli-sh/sdk@0.3.1': {} + '@onecli-sh/sdk@0.5.0': {} '@oxc-project/types@0.124.0': {} diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index e68caf4..941546a 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 141k tokens, 71% of context window + + 150k tokens, 75% of context window @@ -15,8 +15,8 @@ tokens - - 141k + + 150k diff --git a/scripts/q.test.ts b/scripts/q.test.ts new file mode 100644 index 0000000..4901db5 --- /dev/null +++ b/scripts/q.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { spawnSync } from 'child_process'; + +import Database from 'better-sqlite3'; + +/** + * Smoke tests for the q.ts sqlite-CLI replacement wrapper. + * + * Verifies the two modes (SELECT prints rows in sqlite3 default "list" + * format; mutation runs via db.exec) and a few edge cases that real + * skill invocations rely on. + */ + +const Q = path.resolve(__dirname, 'q.ts'); + +describe('scripts/q.ts', () => { + let tempDir: string; + let dbPath: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'q-test-')); + dbPath = path.join(tempDir, 'test.db'); + const db = new Database(dbPath); + db.exec(` + CREATE TABLE t (id INTEGER, name TEXT, note TEXT); + INSERT INTO t (id, name, note) VALUES (1, 'alice', 'hi'), (2, 'bob', NULL); + `); + db.close(); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function run(sql: string): { stdout: string; stderr: string; status: number } { + const r = spawnSync('pnpm', ['exec', 'tsx', Q, dbPath, sql], { + encoding: 'utf-8', + cwd: path.resolve(__dirname, '..'), + }); + return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 }; + } + + it('SELECT prints pipe-separated rows in default order', () => { + const r = run('SELECT id, name FROM t ORDER BY id'); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe('1|alice\n2|bob'); + }); + + it('SELECT renders NULL as empty string (matches sqlite3 default mode)', () => { + const r = run('SELECT id, note FROM t ORDER BY id'); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe('1|hi\n2|'); + }); + + it('SELECT with no rows prints nothing', () => { + const r = run("SELECT id FROM t WHERE name = 'nobody'"); + expect(r.status).toBe(0); + expect(r.stdout).toBe(''); + }); + + it('INSERT runs via db.exec and persists', () => { + const r = run("INSERT INTO t (id, name) VALUES (3, 'carol')"); + expect(r.status).toBe(0); + expect(r.stdout).toBe(''); + + const db = new Database(dbPath, { readonly: true }); + const row = db.prepare('SELECT name FROM t WHERE id = 3').get() as { name: string }; + db.close(); + expect(row.name).toBe('carol'); + }); + + it('compound mutation statements execute together', () => { + const r = run("DELETE FROM t WHERE id = 1; INSERT INTO t (id, name) VALUES (9, 'zed');"); + expect(r.status).toBe(0); + + const db = new Database(dbPath, { readonly: true }); + const ids = (db.prepare('SELECT id FROM t ORDER BY id').all() as { id: number }[]).map( + (r) => r.id, + ); + db.close(); + expect(ids).toEqual([2, 9]); + }); + + it('WITH...DELETE is treated as a mutation, not a query', () => { + const r = run("WITH stale AS (SELECT id FROM t WHERE name = 'alice') DELETE FROM t WHERE id IN (SELECT id FROM stale)"); + expect(r.status).toBe(0); + expect(r.stdout).toBe(''); + + const db = new Database(dbPath, { readonly: true }); + const rows = db.prepare('SELECT name FROM t').all() as { name: string }[]; + db.close(); + expect(rows).toEqual([{ name: 'bob' }]); + }); + + it('exits 2 with usage when args are missing', () => { + const r = spawnSync('pnpm', ['exec', 'tsx', Q], { + encoding: 'utf-8', + cwd: path.resolve(__dirname, '..'), + }); + expect(r.status).toBe(2); + expect(r.stderr).toMatch(/Usage/); + }); +}); diff --git a/scripts/q.ts b/scripts/q.ts new file mode 100644 index 0000000..3d1ba74 --- /dev/null +++ b/scripts/q.ts @@ -0,0 +1,58 @@ +/** + * scripts/q.ts — sqlite3 CLI replacement for skill SQL invocations. + * + * Usage: + * pnpm exec tsx scripts/q.ts "" + * + * Uses better-sqlite3's stmt.reader property to distinguish queries + * (SELECT / WITH...SELECT) from mutations. Queries print rows in + * sqlite3 CLI default ("list") format — pipe-separated, no header — + * so existing skill text reads identically. Mutations run via + * stmt.run() (single statement) or db.exec() (compound). + * + * Why this exists: setup/verify.ts:5 codifies that NanoClaw avoids + * depending on the sqlite3 CLI binary; setup never installs or probes + * for it. Skills that shell out to `sqlite3` therefore fail on hosts + * where it isn't preinstalled (common on fresh Ubuntu — see #2191). + * This wrapper preserves the skill-text shape (path then SQL string) + * while routing through the better-sqlite3 dep that setup already + * installs and verifies. + */ +import Database from 'better-sqlite3'; + +const [, , dbPath, sql] = process.argv; + +if (!dbPath || sql === undefined) { + console.error('Usage: pnpm exec tsx scripts/q.ts ""'); + process.exit(2); +} + +const db = new Database(dbPath); +try { + try { + const stmt = db.prepare(sql); + if (stmt.reader) { + const rows = stmt.all() as Record[]; + for (const row of rows) { + console.log( + Object.values(row) + .map((v) => (v === null ? '' : String(v))) + .join('|'), + ); + } + } else { + stmt.run(); + } + } catch (e: unknown) { + // better-sqlite3 throws on compound statements ("contains more than + // one statement"). Compound SQL in skills is always mutations + // (e.g. "DELETE ...; INSERT ...;"), so fall back to db.exec(). + if (e instanceof Error && /more than one statement/i.test(e.message)) { + db.exec(sql); + } else { + throw e; + } + } +} finally { + db.close(); +} diff --git a/setup/auto.ts b/setup/auto.ts index 91ad83a..5428d03 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -39,7 +39,7 @@ import { runTelegramChannel } from './channels/telegram.js'; import { runWhatsAppChannel } from './channels/whatsapp.js'; import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import { brightSelect } from './lib/bright-select.js'; -import { offerClaudeAssist } from './lib/claude-assist.js'; +import { offerClaudeOnFailure } from './lib/claude-handoff.js'; import { applyToEnv, parseFlags, @@ -416,7 +416,7 @@ async function main(): Promise { } else { phEmit('first_chat_failed', { reason: ping }); renderPingFailureNote(ping); - await offerClaudeAssist({ + await offerClaudeOnFailure({ stepName: 'cli-agent', msg: ping === 'socket_error' @@ -468,7 +468,7 @@ async function main(): Promise { } else if (channelChoice === 'imessage') { result = await runIMessageChannel(displayName!); } else if (channelChoice === 'other') { - await askOtherChannelName(); + result = await askOtherChannelName(); } else { p.log.info( brandBody( @@ -528,7 +528,7 @@ async function main(): Promise { service_running: res.terminal?.fields.SERVICE === 'running', has_credentials: res.terminal?.fields.CREDENTIALS === 'configured', }); - await offerClaudeAssist({ + await offerClaudeOnFailure({ stepName: 'verify', msg: summary || 'Verification completed with unresolved issues.', hint: `Terminal block: ${JSON.stringify(res.terminal?.fields ?? {})}`, @@ -740,12 +740,38 @@ async function runAuthStep(): Promise { label: 'Paste an Anthropic API key', hint: 'pay-per-use via console.anthropic.com', }, + { + value: 'skip', + label: "Skip — I'll connect later", + hint: 'not recommended — Claude helps debug setup issues', + }, ], }), - ) as 'subscription' | 'oauth' | 'api'; + ) as 'subscription' | 'oauth' | 'api' | 'skip'; setupLog.userInput('auth_method', method); phEmit('auth_method_chosen', { method }); + if (method === 'skip') { + const confirmed = ensureAnswer( + await p.confirm({ + message: + "Skip Claude sign-in? The agent won't be able to run until you connect, and we won't be able to help debug setup errors.", + initialValue: false, + }), + ); + if (!confirmed) { + // Loop back to the auth picker so they can choose a real method. + return runAuthStep(); + } + setupLog.step('auth', 'skipped', 0, { REASON: 'user-skipped' }); + p.log.warn( + brandBody( + 'Claude sign-in skipped. Re-run setup or run `bash nanoclaw.sh` to finish later.', + ), + ); + return; + } + if (method === 'subscription') { await runSubscriptionAuth(); } else { @@ -1099,10 +1125,26 @@ async function askChannelChoice(): Promise { return choice; } -async function askOtherChannelName(): Promise { +async function askOtherChannelName(): Promise { + const action = ensureAnswer( + await brightSelect<'type' | 'back'>({ + message: 'Which channel would you like to install?', + options: [ + { + value: 'type', + label: 'Type the channel name', + hint: 'e.g. matrix, github, linear, webex', + }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'type', + }), + ); + if (action === 'back') return BACK_TO_CHANNEL_SELECTION; + const answer = ensureAnswer( await p.text({ - message: 'Which channel would you like to install?', + message: 'Channel name', placeholder: 'e.g. matrix, github, linear, webex', }), ); diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index 5730fca..c7c2b77 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -290,7 +290,8 @@ async function askOperatorHandle(): Promise { "What phone number or email do you iMessage with?", "That's where your assistant will send its welcome message.", '', - k.dim(' • Phone: full E.164, e.g. +15551234567'), + k.dim(' • Phone: start with + and your country code, no spaces or dashes'), + k.dim(' Example: +14155551234 (country code 1, then 4155551234)'), k.dim(' • Email: whatever iMessage recognises (Apple ID, iCloud alias, …)'), ].join('\n'), 'Your iMessage handle', diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 0918075..f4bbdd1 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -27,7 +27,7 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; import { brightSelect } from '../lib/bright-select.js'; -import { formatNoteLink, openUrl } from '../lib/browser.js'; +import { openUrl } from '../lib/browser.js'; import { isHeadless } from '../platform.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; @@ -126,22 +126,31 @@ export async function runSlackChannel(displayName: string): Promise { + // Bright-white ANSI overrides the surrounding brand-cyan from `note()`'s + // per-line formatter so the URL stands out against the rest of the body. + const linkBlock = isHeadless() + ? [`\x1b[97mGet started: ${SLACK_APPS_URL}\x1b[39m`, ''] + : []; + note( [ "You'll create a Slack app that the assistant talks through.", "Free and stays inside the workspaces you pick.", '', + ...linkBlock, ' 1. Create a new app "From scratch", name it, pick a workspace', ' 2. OAuth & Permissions → add Bot Token Scopes:', - ' chat:write, im:write, channels:history, groups:history,', - ' im:history, channels:read, groups:read, users:read,', - ' reactions:write', + ' • im:write, im:history', + ' • channels:read, channels:history', + ' • groups:read, groups:history', + ' • chat:write', + ' • users:read', + ' • reactions:write', ' 3. App Home → enable "Messages Tab" and "Allow users to send', ' slash commands and messages from the messages tab"', ' 4. Basic Information → copy the "Signing Secret"', ' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)', - formatNoteLink(SLACK_APPS_URL), - ].filter((line): line is string => line !== null).join('\n'), + ].join('\n'), 'Create a Slack app', ); diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index 3691beb..9375995 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -95,12 +95,25 @@ export async function runTeamsChannel(_displayName: string): Promise { +}): Promise<'continue' | 'back'> { note( [ `1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`, @@ -262,15 +275,17 @@ async function stepAppRegistration(args: { ); } - await stepGate({ + const gate = await stepGate({ stepName: 'teams-app-registration', stepDescription: 'registering an app in Azure and collecting App ID + tenant type', reshow: () => stepAppRegistration(args), args, }); + if (gate === 'back') return 'back'; args.completed.push( `App registered: ${args.collected.appId} (${args.collected.appType})`, ); + return 'continue'; } async function askAppType(args: { @@ -313,7 +328,7 @@ async function askAppType(args: { async function stepClientSecret(args: { collected: Collected; completed: string[]; -}): Promise { +}): Promise<'continue' | 'back'> { note( [ `1. In your app registration, open "Certificates & secrets"`, @@ -356,13 +371,15 @@ async function stepClientSecret(args: { break; } - await stepGate({ + const gate = await stepGate({ stepName: 'teams-client-secret', stepDescription: 'creating and copying the client secret', reshow: () => stepClientSecret(args), args, }); + if (gate === 'back') return 'back'; args.completed.push('Client secret captured.'); + return 'continue'; } // ─── step: Azure Bot resource ────────────────────────────────────────── @@ -370,7 +387,7 @@ async function stepClientSecret(args: { async function stepAzureBot(args: { collected: Collected; completed: string[]; -}): Promise { +}): Promise<'continue' | 'back'> { const endpoint = `${args.collected.publicUrl}/api/webhooks/teams`; const tenantFlag = args.collected.appType === 'SingleTenant' @@ -405,14 +422,16 @@ async function stepAzureBot(args: { 'Step 3 of 6 — Create Azure Bot resource', ); - await stepGate({ + const gate = await stepGate({ stepName: 'teams-azure-bot', stepDescription: 'creating an Azure Bot resource linked to the app registration and setting the messaging endpoint', reshow: () => stepAzureBot(args), args, }); + if (gate === 'back') return 'back'; args.completed.push('Azure Bot created; messaging endpoint configured.'); + return 'continue'; } // ─── step: enable Teams channel ──────────────────────────────────────── @@ -420,7 +439,7 @@ async function stepAzureBot(args: { async function stepEnableTeamsChannel(args: { collected: Collected; completed: string[]; -}): Promise { +}): Promise<'continue' | 'back'> { note( [ '1. Open your Azure Bot resource → Channels', @@ -431,13 +450,15 @@ async function stepEnableTeamsChannel(args: { ].join('\n'), 'Step 4 of 6 — Enable Teams channel on the bot', ); - await stepGate({ + const gate = await stepGate({ stepName: 'teams-enable-channel', stepDescription: 'enabling the Microsoft Teams channel on the Azure Bot resource', reshow: () => stepEnableTeamsChannel(args), args, }); + if (gate === 'back') return 'back'; args.completed.push('Teams channel enabled on the bot.'); + return 'continue'; } // ─── step: manifest zip ──────────────────────────────────────────────── @@ -490,7 +511,7 @@ async function stepSideload(args: { collected: Collected; completed: string[]; zipPath: string; -}): Promise { +}): Promise<'continue' | 'back'> { note( [ '1. Open Microsoft Teams', @@ -505,13 +526,15 @@ async function stepSideload(args: { ].join('\n'), 'Step 5 of 6 — Sideload the app into Teams', ); - await stepGate({ + const gate = await stepGate({ stepName: 'teams-sideload', stepDescription: 'uploading the generated zip into Teams as a custom app', - reshow: () => stepSideload(args), + reshow: () => stepSideload({ ...args, zipPath: args.zipPath }), args, }); + if (gate === 'back') return 'back'; args.completed.push('App sideloaded into Teams.'); + return 'continue'; } // ─── step: install adapter ───────────────────────────────────────────── @@ -623,9 +646,9 @@ async function finishWithHandoff( async function stepGate(args: { stepName: string; stepDescription: string; - reshow: () => Promise | Promise; + reshow: () => Promise<'continue' | 'back'>; args: { collected: Collected; completed: string[] }; -}): Promise { +}): Promise<'continue' | 'back'> { while (true) { const choice = ensureAnswer( await brightSelect({ @@ -634,10 +657,12 @@ async function stepGate(args: { { value: 'done', label: "Done — let's continue" }, { value: 'help', label: 'Stuck — hand me off to Claude' }, { value: 'reshow', label: 'Show me the steps again' }, + { value: 'back', label: '← Back to channel selection' }, ], }), ); - if (choice === 'done') return; + if (choice === 'done') return 'continue'; + if (choice === 'back') return 'back'; if (choice === 'help') { await offerHandoff({ step: args.stepName, @@ -647,8 +672,7 @@ async function stepGate(args: { continue; } if (choice === 'reshow') { - await args.reshow(); - return; + return args.reshow(); } } } diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index 187377e..8c0910d 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -43,7 +43,7 @@ export interface AssistContext { * rather than us stuffing contents into the prompt. Keys are step names as * they appear in fail() calls; values are repo-relative paths. */ -const STEP_FILES: Record = { +export const STEP_FILES: Record = { bootstrap: ['setup.sh', 'setup/install-node.sh', 'nanoclaw.sh'], environment: ['setup/environment.ts'], container: [ @@ -81,7 +81,7 @@ const STEP_FILES: Record = { ], }; -const BIG_PICTURE_FILES = ['README.md', 'setup/auto.ts']; +export const BIG_PICTURE_FILES = ['README.md', 'setup/auto.ts']; /** * Returns `true` if the user ran a Claude-suggested fix command; callers @@ -150,7 +150,7 @@ function isClaudeAuthenticated(): boolean { } } -async function ensureClaudeReady(projectRoot: string): Promise { +export async function ensureClaudeReady(projectRoot: string): Promise { if (!isClaudeInstalled()) { const install = ensureAnswer( await p.confirm({ diff --git a/setup/lib/claude-handoff.ts b/setup/lib/claude-handoff.ts index 87023ef..892b397 100644 --- a/setup/lib/claude-handoff.ts +++ b/setup/lib/claude-handoff.ts @@ -23,10 +23,19 @@ * attempting to parse it as a real answer. */ import { execSync, spawn } from 'child_process'; +import path from 'path'; import * as p from '@clack/prompts'; import k from 'kleur'; +import { + type AssistContext, + BIG_PICTURE_FILES, + ensureClaudeReady, + offerClaudeAssist, + STEP_FILES, +} from './claude-assist.js'; +import { ensureAnswer } from './runner.js'; import { brandBody, note } from './theme.js'; export interface HandoffContext { @@ -194,3 +203,110 @@ function buildSystemPrompt(ctx: HandoffContext): string { return lines.join('\n'); } + +/** + * Dispatcher: checks NANOCLAW_SETUP_ASSIST_MODE and delegates to either + * the interactive failure handoff (default) or the non-interactive assist. + * + * Drop-in replacement for `offerClaudeAssist` at failure call sites. + */ +export async function offerClaudeOnFailure( + ctx: AssistContext, + projectRoot: string = process.cwd(), +): Promise { + if (process.env.NANOCLAW_SETUP_ASSIST_MODE === 'true' || process.env.NANOCLAW_SETUP_ASSIST_MODE === '1') { + return offerClaudeAssist(ctx, projectRoot); + } + return offerFailureHandoff(ctx, projectRoot); +} + +/** + * Interactive Claude handoff for setup failures. Same role as + * `offerClaudeAssist` but spawns an interactive session instead of + * parsing a structured REASON/COMMAND response. + * + * Returns `true` if Claude was launched (the user may have fixed + * things during the session), `false` if skipped/declined/unavailable. + */ +async function offerFailureHandoff( + ctx: AssistContext, + projectRoot: string, +): Promise { + if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false; + if (!(await ensureClaudeReady(projectRoot))) return false; + + const want = ensureAnswer( + await p.confirm({ + message: 'Want to debug this with Claude?', + initialValue: true, + }), + ); + if (!want) return false; + + const systemPrompt = buildFailureSystemPrompt(ctx, projectRoot); + + note( + [ + "Launching Claude to help debug this failure.", + "It has the context of what went wrong.", + "", + k.dim("Type /exit (or press Ctrl-D) when you're ready to come back to setup."), + ].join('\n'), + 'Handing off to Claude', + ); + + return new Promise((resolve) => { + const child = spawn( + 'claude', + [ + '--append-system-prompt', + systemPrompt, + '--permission-mode', + 'acceptEdits', + ], + { stdio: 'inherit' }, + ); + child.on('close', () => { + p.log.success(brandBody("Back from Claude. Let's continue.")); + resolve(true); + }); + child.on('error', () => { + p.log.error("Couldn't launch Claude. Continuing without handoff."); + resolve(false); + }); + }); +} + +function buildFailureSystemPrompt(ctx: AssistContext, projectRoot: string): string { + const stepRefs = STEP_FILES[ctx.stepName] ?? []; + const references = [ + ...BIG_PICTURE_FILES, + ...stepRefs, + 'logs/setup.log', + ctx.rawLogPath + ? path.relative(projectRoot, ctx.rawLogPath) + : 'logs/setup-steps/', + ].filter((v, i, a) => a.indexOf(v) === i); + + const lines: string[] = [ + "The user is running NanoClaw's interactive setup flow and hit a failure.", + '', + `Failed step: ${ctx.stepName}`, + `Error: ${ctx.msg}`, + ]; + + if (ctx.hint) lines.push(`Hint: ${ctx.hint}`); + + lines.push( + '', + 'Your job: help them diagnose and fix this issue. Read the referenced files', + 'and logs to understand what went wrong, then help them fix it. You can read', + 'files, run commands, check logs, and explain what happened. Be concise.', + "When they're ready to resume setup, tell them to type /exit.", + '', + 'Relevant files (read as needed with the Read tool):', + ); + for (const f of references) lines.push(` - ${f}`); + + return lines.join('\n'); +} diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts index 6ffffed..6adb02e 100644 --- a/setup/lib/runner.ts +++ b/setup/lib/runner.ts @@ -18,7 +18,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; -import { offerClaudeAssist } from './claude-assist.js'; +import { offerClaudeOnFailure } from './claude-handoff.js'; import { emit as phEmit } from './diagnostics.js'; import { brandBody, fitToWidth, fmtDuration } from './theme.js'; @@ -367,7 +367,7 @@ export async function fail( if (hint) p.log.message(k.dim(hint)); p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/')); - const ranFix = await offerClaudeAssist({ stepName, msg, hint, rawLogPath }); + const ranFix = await offerClaudeOnFailure({ stepName, msg, hint, rawLogPath }); // If the user just ran a Claude-suggested fix, offer to resume the flow // at the step that failed instead of aborting. We re-exec via spawnSync diff --git a/setup/lib/setup-config.ts b/setup/lib/setup-config.ts index 1fa6ad4..b8eb654 100644 --- a/setup/lib/setup-config.ts +++ b/setup/lib/setup-config.ts @@ -123,6 +123,15 @@ export const CONFIG: Entry[] = [ surface: 'flag', type: 'string', }, + { + key: 'assistMode', + envVar: 'NANOCLAW_SETUP_ASSIST_MODE', + label: 'Assist mode', + help: 'Use non-interactive Claude assist on failure instead of interactive handoff.', + surface: 'flag', + type: 'boolean', + default: false, + }, ]; // ─── name derivation ─────────────────────────────────────────────────── diff --git a/setup/lib/windowed-runner.ts b/setup/lib/windowed-runner.ts index 87c971e..f13dcd3 100644 --- a/setup/lib/windowed-runner.ts +++ b/setup/lib/windowed-runner.ts @@ -18,7 +18,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; -import { offerClaudeAssist } from './claude-assist.js'; +import { offerClaudeOnFailure } from './claude-handoff.js'; import { emit as phEmit } from './diagnostics.js'; import type { StepResult, SpinnerLabels } from './runner.js'; import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js'; @@ -212,7 +212,7 @@ async function handleStall( // offerClaudeAssist runs its own spinner and may propose a fix command. // We don't attempt to restart the stalled build from here — if Claude // proposes a command the user accepts, they can retry setup afterwards. - await offerClaudeAssist({ + await offerClaudeOnFailure({ stepName, msg: `The ${stepName} step has produced no output for 60 seconds.`, hint: 'It may be hung on a slow network pull or a failing Dockerfile step.', diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index f403dfa..efeb32f 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -307,8 +307,14 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // Start local HTTP server to receive forwarded Gateway events (including interactions) const webhookUrl = await startLocalWebhookServer(gatewayAdapter, setupConfig, config.botToken); + // Exponential backoff capped at 1h. Without this, an unrecoverable + // failure (e.g., TokenInvalid) restarts ~10×/sec and Discord's + // Cloudflare layer issues a multi-hour IP block. A run that lasts + // longer than 5 minutes counts as healthy and resets the counter. + let consecutiveFailures = 0; const startGateway = () => { if (gatewayAbort?.signal.aborted) return; + const startedAt = Date.now(); // Capture the long-running listener promise via waitUntil let listenerPromise: Promise | undefined; gatewayAdapter.startGatewayListener!( @@ -323,21 +329,30 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter ).then(() => { // startGatewayListener resolves immediately with a Response; // the actual work is in the listenerPromise passed to waitUntil - if (listenerPromise) { - listenerPromise - .then(() => { - if (!gatewayAbort?.signal.aborted) { - log.info('Gateway listener expired, restarting', { adapter: adapter.name }); - startGateway(); - } - }) - .catch((err) => { - if (!gatewayAbort?.signal.aborted) { - log.error('Gateway listener error, restarting in 5s', { adapter: adapter.name, err }); - setTimeout(startGateway, 5000); - } + if (!listenerPromise) return; + const reschedule = (err?: unknown) => { + if (gatewayAbort?.signal.aborted) return; + const ranForMs = Date.now() - startedAt; + if (ranForMs > 5 * 60 * 1000) consecutiveFailures = 0; + else consecutiveFailures++; + const delayMs = Math.min(60 * 60 * 1000, 2 ** consecutiveFailures * 1000); + if (err) { + log.error('Gateway listener error, retrying', { + adapter: adapter.name, + err, + consecutiveFailures, + delayMs, }); - } + } else { + log.info('Gateway listener expired, restarting', { + adapter: adapter.name, + consecutiveFailures, + delayMs, + }); + } + setTimeout(startGateway, delayMs); + }; + listenerPromise.then(() => reschedule()).catch(reschedule); }); }; startGateway(); diff --git a/src/db/schema.ts b/src/db/schema.ts index 8433035..48d9ce3 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -171,7 +171,13 @@ CREATE TABLE IF NOT EXISTS messages_in ( platform_id TEXT, channel_type TEXT, thread_id TEXT, - content TEXT NOT NULL + content TEXT NOT NULL, + -- For agent-to-agent inbound rows: the source session that emitted the + -- triggering outbound. Used as a return path when the target replies — + -- the reply routes back to this exact session, not to the source agent + -- group's "newest" session. NULL on channel-side inbound and on a2a rows + -- written before this column existed. + source_session_id TEXT ); CREATE INDEX IF NOT EXISTS idx_messages_in_series ON messages_in(series_id); diff --git a/src/db/session-db.test.ts b/src/db/session-db.test.ts index 5307900..a202100 100644 --- a/src/db/session-db.test.ts +++ b/src/db/session-db.test.ts @@ -10,7 +10,7 @@ import fs from 'fs'; import path from 'path'; import { describe, it, expect, afterEach } from 'vitest'; -import { migrateMessagesInTable } from './session-db.js'; +import { getInboundSourceSessionId, migrateMessagesInTable } from './session-db.js'; const TEST_DIR = '/tmp/nanoclaw-session-db-test'; const DB_PATH = path.join(TEST_DIR, 'inbound.db'); @@ -55,4 +55,40 @@ describe('migrateMessagesInTable', () => { expect(row.series_id).toBe('legacy-1'); db.close(); }); + + it('adds source_session_id on a legacy DB, leaves existing rows NULL, is idempotent', () => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); + + const db = new Database(DB_PATH); + db.exec(` + CREATE TABLE messages_in ( + id TEXT PRIMARY KEY, + seq INTEGER UNIQUE, + kind TEXT NOT NULL, + timestamp TEXT NOT NULL, + status TEXT DEFAULT 'pending', + process_after TEXT, + recurrence TEXT, + tries INTEGER DEFAULT 0, + platform_id TEXT, + channel_type TEXT, + thread_id TEXT, + content TEXT NOT NULL + ); + `); + db.prepare( + "INSERT INTO messages_in (id, seq, kind, timestamp, status, content) VALUES (?, ?, 'chat', datetime('now'), 'pending', '{}')", + ).run('legacy-2', 2); + + migrateMessagesInTable(db); + migrateMessagesInTable(db); // idempotent + + const cols = (db.prepare("PRAGMA table_info('messages_in')").all() as Array<{ name: string }>).map((c) => c.name); + expect(cols).toContain('source_session_id'); + + expect(getInboundSourceSessionId(db, 'legacy-2')).toBeNull(); + expect(getInboundSourceSessionId(db, 'does-not-exist')).toBeNull(); + db.close(); + }); }); diff --git a/src/db/session-db.ts b/src/db/session-db.ts index addc39d..6713702 100644 --- a/src/db/session-db.ts +++ b/src/db/session-db.ts @@ -108,14 +108,21 @@ export function insertMessage( * Host countDueMessages gates on this; container reads everything. */ trigger?: 0 | 1; + /** + * For agent-to-agent inbound: the source session id that emitted the + * outbound message which became this inbound row. Used as the return + * path for the target's reply. NULL on channel-side inbound. + */ + sourceSessionId?: string | null; }, ): void { db.prepare( - `INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence, series_id, trigger) - VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence, @id, @trigger)`, + `INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence, series_id, trigger, source_session_id) + VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence, @id, @trigger, @sourceSessionId)`, ).run({ ...message, trigger: message.trigger ?? 1, + sourceSessionId: message.sourceSessionId ?? null, seq: nextEvenSeq(db), }); } @@ -239,6 +246,7 @@ export interface OutboundMessage { channel_type: string | null; thread_id: string | null; content: string; + in_reply_to: string | null; } export function getDueOutboundMessages(db: Database.Database): OutboundMessage[] { @@ -305,4 +313,47 @@ export function migrateMessagesInTable(db: Database.Database): void { // the agent" semantics, so backfill 1 and default 1 for new inserts. db.prepare('ALTER TABLE messages_in ADD COLUMN trigger INTEGER NOT NULL DEFAULT 1').run(); } + if (!cols.has('source_session_id')) { + // For agent-to-agent return-path routing. NULL on existing rows is fine — + // their replies fall back to the legacy "newest active session" lookup. + db.prepare('ALTER TABLE messages_in ADD COLUMN source_session_id TEXT').run(); + } +} + +/** + * Look up an inbound row's source_session_id by its message id. Returns null + * if the row doesn't exist or the column is NULL (channel inbound or + * pre-migration a2a inbound). Used by a2a routing to route replies back to + * the originating session. + */ +export function getInboundSourceSessionId(db: Database.Database, messageId: string): string | null { + const row = db.prepare('SELECT source_session_id FROM messages_in WHERE id = ?').get(messageId) as + | { source_session_id: string | null } + | undefined; + return row?.source_session_id ?? null; +} + +/** + * Find the source_session_id of the most recent a2a inbound row from a + * specific peer (by agent group id). Used as a peer-affinity fallback in + * a2a routing when an outbound reply has no `in_reply_to` (e.g. the + * container's send_message MCP tool path didn't thread the batch's + * in_reply_to through). + * + * Heuristic: "the last time this peer talked to me, which session was it?" + * Returns null when no prior a2a inbound from that peer carries a + * non-null source_session_id (typical for pre-migration installs). + */ +export function getMostRecentPeerSourceSessionId(db: Database.Database, peerAgentGroupId: string): string | null { + const row = db + .prepare( + `SELECT source_session_id FROM messages_in + WHERE channel_type = 'agent' + AND platform_id = ? + AND source_session_id IS NOT NULL + ORDER BY seq DESC + LIMIT 1`, + ) + .get(peerAgentGroupId) as { source_session_id: string | null } | undefined; + return row?.source_session_id ?? null; } diff --git a/src/delivery.test.ts b/src/delivery.test.ts index a5e1efd..5d23536 100644 --- a/src/delivery.test.ts +++ b/src/delivery.test.ts @@ -26,8 +26,9 @@ vi.mock('./config.js', async () => { const TEST_DIR = '/tmp/nanoclaw-test-delivery'; -import { initTestDb, closeDb, runMigrations, createAgentGroup, createMessagingGroup } from './db/index.js'; -import { resolveSession, outboundDbPath } from './session-manager.js'; +import { initTestDb, closeDb, runMigrations, createAgentGroup, createMessagingGroup, createMessagingGroupAgent } from './db/index.js'; +import { getDeliveredIds } from './db/session-db.js'; +import { resolveSession, outboundDbPath, openInboundDb } from './session-manager.js'; import { deliverSessionMessages, setDeliveryAdapter } from './delivery.js'; function now(): string { @@ -146,3 +147,118 @@ describe('deliverSessionMessages — concurrent invocations', () => { expect(callCount).toBe(1); }); }); + +describe('deliverSessionMessages — retry and permanent failure', () => { + it('retries on adapter failure and marks failed after MAX_DELIVERY_ATTEMPTS (3)', async () => { + seedAgentAndChannel(); + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + insertOutbound('ag-1', session.id, 'out-flaky'); + + let callCount = 0; + setDeliveryAdapter({ + async deliver() { + callCount++; + throw new Error('network timeout'); + }, + }); + + // Attempt 1 + await deliverSessionMessages(session); + expect(callCount).toBe(1); + + // Attempt 2 + await deliverSessionMessages(session); + expect(callCount).toBe(2); + + // Attempt 3 — should mark as permanently failed + await deliverSessionMessages(session); + expect(callCount).toBe(3); + + // Attempt 4 — message is now in delivered (as failed), adapter not called + await deliverSessionMessages(session); + expect(callCount).toBe(3); + + // Verify the message is in the delivered table with 'failed' status + const inDb = openInboundDb('ag-1', session.id); + const delivered = getDeliveredIds(inDb); + inDb.close(); + expect(delivered.has('out-flaky')).toBe(true); + }); + + it('clears attempt counter on successful delivery', async () => { + seedAgentAndChannel(); + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + insertOutbound('ag-1', session.id, 'out-retry-ok'); + + let callCount = 0; + setDeliveryAdapter({ + async deliver() { + callCount++; + if (callCount === 1) throw new Error('transient'); + return 'plat-ok'; + }, + }); + + // Attempt 1 — fails + await deliverSessionMessages(session); + expect(callCount).toBe(1); + + // Attempt 2 — succeeds + await deliverSessionMessages(session); + expect(callCount).toBe(2); + + // Attempt 3 — not called, message already delivered + await deliverSessionMessages(session); + expect(callCount).toBe(2); + }); +}); + +describe('deliverSessionMessages — permission check', () => { + it('rejects delivery to an unauthorized channel destination', async () => { + seedAgentAndChannel(); + + // Create a second messaging group that the agent is NOT wired to + createMessagingGroup({ + id: 'mg-2', + channel_type: 'discord', + platform_id: 'discord:456', + name: 'Unauthorized Chat', + is_group: 0, + unknown_sender_policy: 'public', + created_at: now(), + }); + + // Session is on mg-1 (telegram) + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + + // Insert an outbound message targeting mg-2 (discord) — not the origin chat + const outDb = new Database(outboundDbPath('ag-1', session.id)); + outDb.prepare( + `INSERT INTO messages_out (id, timestamp, kind, platform_id, channel_type, content) + VALUES (?, datetime('now'), 'chat', 'discord:456', 'discord', ?)`, + ).run('out-unauth', JSON.stringify({ text: 'sneaky' })); + outDb.close(); + + const calls: string[] = []; + setDeliveryAdapter({ + async deliver(_ct, _pid, _tid, _kind, content) { + calls.push(content); + return 'plat-msg'; + }, + }); + + // Deliver 3 times to exhaust retries + await deliverSessionMessages(session); + await deliverSessionMessages(session); + await deliverSessionMessages(session); + + // Adapter never called — permission check throws before reaching it + expect(calls).toHaveLength(0); + + // Message is marked as permanently failed + const inDb = openInboundDb('ag-1', session.id); + const delivered = getDeliveredIds(inDb); + inDb.close(); + expect(delivered.has('out-unauth')).toBe(true); + }); +}); diff --git a/src/delivery.ts b/src/delivery.ts index 036153a..a47fec2 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -239,6 +239,7 @@ async function deliverMessage( channel_type: string | null; thread_id: string | null; content: string; + in_reply_to: string | null; }, session: Session, inDb: Database.Database, diff --git a/src/group-init.ts b/src/group-init.ts index 437d10f..b325150 100644 --- a/src/group-init.ts +++ b/src/group-init.ts @@ -14,6 +14,18 @@ const DEFAULT_SETTINGS_JSON = CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', }, + hooks: { + PreCompact: [ + { + hooks: [ + { + type: 'command', + command: 'bun /app/src/compact-instructions.ts', + }, + ], + }, + ], + }, }, null, 2, @@ -71,6 +83,8 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s if (!fs.existsSync(settingsFile)) { fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON); initialized.push('settings.json'); + } else { + ensurePreCompactHook(settingsFile, initialized); } // Skills directory — created empty here; symlinks are synced at spawn @@ -90,3 +104,32 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s }); } } + +const PRE_COMPACT_COMMAND = 'bun /app/src/compact-instructions.ts'; + +/** + * Patch an existing settings.json to add the PreCompact hook if missing. + * Runs on every group init so pre-existing groups pick up the hook. + */ +function ensurePreCompactHook(settingsFile: string, initialized: string[]): void { + try { + const raw = fs.readFileSync(settingsFile, 'utf-8'); + const settings = JSON.parse(raw); + + // Check if there's already a PreCompact hook with our command. + const existing = settings.hooks?.PreCompact as unknown[] | undefined; + if (existing && JSON.stringify(existing).includes(PRE_COMPACT_COMMAND)) return; + + // Add the hook, preserving existing hooks. + if (!settings.hooks) settings.hooks = {}; + if (!settings.hooks.PreCompact) settings.hooks.PreCompact = []; + settings.hooks.PreCompact.push({ + hooks: [{ type: 'command', command: PRE_COMPACT_COMMAND }], + }); + + fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n'); + initialized.push('settings.json (added PreCompact hook)'); + } catch { + // Don't break init if settings.json is malformed — it'll use whatever's there. + } +} diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 043b6b1..1225b76 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { initTestDb, closeDb, + getDb, runMigrations, createAgentGroup, createMessagingGroup, @@ -19,6 +20,7 @@ import { import { resolveSession, writeSessionMessage, + writeSessionRouting, initSessionFolder, sessionDir, inboundDbPath, @@ -595,6 +597,396 @@ describe('router', () => { }); }); +describe('routing metadata preservation', () => { + beforeEach(() => { + createAgentGroup({ + id: 'ag-1', + name: 'Test Agent', + folder: 'test-agent', + agent_provider: null, + created_at: now(), + }); + createMessagingGroup({ + id: 'mg-1', + channel_type: 'discord', + platform_id: 'chan-123', + name: 'General', + is_group: 1, + unknown_sender_policy: 'public', + created_at: now(), + }); + createMessagingGroupAgent({ + id: 'mga-1', + messaging_group_id: 'mg-1', + agent_group_id: 'ag-1', + engage_mode: 'pattern', + engage_pattern: '.', + sender_scope: 'all', + ignored_message_policy: 'drop', + session_mode: 'shared', + priority: 0, + created_at: now(), + }); + }); + + it('routed message carries platformId, channelType, threadId on the messages_in row', async () => { + const { routeInbound } = await import('./router.js'); + + await routeInbound({ + channelType: 'discord', + platformId: 'chan-123', + threadId: 'thread-42', + message: { id: 'msg-r1', kind: 'chat', content: JSON.stringify({ sender: 'A', text: 'hi' }), timestamp: now() }, + }); + + const session = findSession('mg-1', null); + const db = new Database(inboundDbPath('ag-1', session!.id)); + const row = db + .prepare('SELECT platform_id, channel_type, thread_id FROM messages_in WHERE id LIKE ?') + .get('msg-r1%') as { + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + }; + db.close(); + + expect(row.platform_id).toBe('chan-123'); + expect(row.channel_type).toBe('discord'); + expect(row.thread_id).toBe('thread-42'); + }); + + it('fan-out gives each agent its own routing, not leaked from sibling', async () => { + const { routeInbound } = await import('./router.js'); + + createAgentGroup({ + id: 'ag-2', + name: 'Agent Two', + folder: 'agent-two', + agent_provider: null, + created_at: now(), + }); + createMessagingGroupAgent({ + id: 'mga-2', + messaging_group_id: 'mg-1', + agent_group_id: 'ag-2', + engage_mode: 'pattern', + engage_pattern: '.', + sender_scope: 'all', + ignored_message_policy: 'drop', + session_mode: 'shared', + priority: 0, + created_at: now(), + }); + + await routeInbound({ + channelType: 'discord', + platformId: 'chan-123', + threadId: 'thread-fanout', + message: { id: 'msg-fo', kind: 'chat', content: JSON.stringify({ text: 'fan' }), timestamp: now() }, + }); + + // Both agents should have the message with correct routing + const { getSessionsByAgentGroup } = await import('./db/sessions.js'); + for (const agId of ['ag-1', 'ag-2']) { + const sessions = getSessionsByAgentGroup(agId); + expect(sessions).toHaveLength(1); + const db = new Database(inboundDbPath(agId, sessions[0].id)); + const row = db.prepare('SELECT platform_id, channel_type, thread_id FROM messages_in LIMIT 1').get() as { + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + }; + db.close(); + expect(row.platform_id).toBe('chan-123'); + expect(row.channel_type).toBe('discord'); + expect(row.thread_id).toBe('thread-fanout'); + } + }); +}); + +describe('writeSessionRouting', () => { + it('populates session_routing from the messaging group', () => { + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + agent_provider: null, + created_at: now(), + }); + createMessagingGroup({ + id: 'mg-1', + channel_type: 'telegram', + platform_id: 'tg:12345', + name: 'Chat', + is_group: 0, + unknown_sender_policy: 'public', + created_at: now(), + }); + + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + writeSessionRouting('ag-1', session.id); + + const db = new Database(inboundDbPath('ag-1', session.id)); + const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as + | { + channel_type: string | null; + platform_id: string | null; + thread_id: string | null; + } + | undefined; + db.close(); + + expect(row).toBeDefined(); + expect(row!.channel_type).toBe('telegram'); + expect(row!.platform_id).toBe('tg:12345'); + expect(row!.thread_id).toBeNull(); + }); + + it('writes null routing for agent-shared session (no messaging group)', () => { + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + agent_provider: null, + created_at: now(), + }); + + const { session } = resolveSession('ag-1', null, null, 'agent-shared'); + writeSessionRouting('ag-1', session.id); + + const db = new Database(inboundDbPath('ag-1', session.id)); + const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as + | { + channel_type: string | null; + platform_id: string | null; + thread_id: string | null; + } + | undefined; + db.close(); + + expect(row).toBeDefined(); + expect(row!.channel_type).toBeNull(); + expect(row!.platform_id).toBeNull(); + expect(row!.thread_id).toBeNull(); + }); + + it('includes thread_id from per-thread session', () => { + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + agent_provider: null, + created_at: now(), + }); + createMessagingGroup({ + id: 'mg-1', + channel_type: 'discord', + platform_id: 'chan-123', + name: 'General', + is_group: 1, + unknown_sender_policy: 'public', + created_at: now(), + }); + + const { session } = resolveSession('ag-1', 'mg-1', 'thread-77', 'per-thread'); + writeSessionRouting('ag-1', session.id); + + const db = new Database(inboundDbPath('ag-1', session.id)); + const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as + | { + channel_type: string | null; + platform_id: string | null; + thread_id: string | null; + } + | undefined; + db.close(); + + expect(row).toBeDefined(); + expect(row!.channel_type).toBe('discord'); + expect(row!.platform_id).toBe('chan-123'); + expect(row!.thread_id).toBe('thread-77'); + }); +}); + +describe('agent-shared session resolution', () => { + it('resolves to the same session on repeated calls', () => { + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + agent_provider: null, + created_at: now(), + }); + + const { session: s1, created: c1 } = resolveSession('ag-1', null, null, 'agent-shared'); + const { session: s2, created: c2 } = resolveSession('ag-1', null, null, 'agent-shared'); + + expect(c1).toBe(true); + expect(c2).toBe(false); + expect(s1.id).toBe(s2.id); + }); + + it('agent-shared session has null messaging_group_id', () => { + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + agent_provider: null, + created_at: now(), + }); + + const { session } = resolveSession('ag-1', null, null, 'agent-shared'); + expect(session.messaging_group_id).toBeNull(); + }); + +}); + +describe('agent-to-agent routing', () => { + beforeEach(() => { + createAgentGroup({ + id: 'ag-pa', + name: 'PA', + folder: 'pa-agent', + agent_provider: null, + created_at: now(), + }); + createMessagingGroup({ + id: 'mg-slack', + channel_type: 'slack', + platform_id: 'C-GENERAL', + name: 'Slack General', + is_group: 1, + unknown_sender_policy: 'public', + created_at: now(), + }); + createAgentGroup({ + id: 'ag-researcher', + name: 'Researcher', + folder: 'researcher-agent', + agent_provider: null, + created_at: now(), + }); + + // Wire bidirectional A2A destinations (table created by runMigrations) + const db = getDb(); + db.prepare( + `INSERT OR IGNORE INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at) + VALUES ('ag-pa', 'researcher', 'agent', 'ag-researcher', ?)`, + ).run(now()); + db.prepare( + `INSERT OR IGNORE INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at) + VALUES ('ag-researcher', 'pa', 'agent', 'ag-pa', ?)`, + ).run(now()); + }); + + it('A2A outbound lands in a session for the target agent', async () => { + const { routeAgentMessage } = await import('./modules/agent-to-agent/agent-route.js'); + + const { session: paSlackSession } = resolveSession('ag-pa', 'mg-slack', null, 'shared'); + + await routeAgentMessage( + { id: 'out-a2a-1', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'research this' }), in_reply_to: null }, + paSlackSession, + ); + + const { getSessionsByAgentGroup } = await import('./db/sessions.js'); + const researcherSessions = getSessionsByAgentGroup('ag-researcher'); + expect(researcherSessions.length).toBeGreaterThanOrEqual(1); + + const rDb = new Database(inboundDbPath('ag-researcher', researcherSessions[0].id)); + const rows = rDb.prepare('SELECT platform_id, channel_type, content FROM messages_in').all() as Array<{ + platform_id: string | null; + channel_type: string | null; + content: string; + }>; + rDb.close(); + + expect(rows).toHaveLength(1); + expect(rows[0].channel_type).toBe('agent'); + expect(rows[0].platform_id).toBe('ag-pa'); + expect(JSON.parse(rows[0].content).text).toBe('research this'); + }); + + it('A2A return path routes to originating session, not newest (#2332)', async () => { + // PA has Slack session, then gets wired to Discord (newer session). + // Researcher responds to PA. With the return-path fix, the reply + // routes back to the Slack session (originator) not Discord (newest). + const { routeAgentMessage } = await import('./modules/agent-to-agent/agent-route.js'); + + const { session: paSlackSession } = resolveSession('ag-pa', 'mg-slack', null, 'shared'); + + createMessagingGroup({ + id: 'mg-discord', + channel_type: 'discord', + platform_id: 'chan-discord', + name: 'Discord', + is_group: 0, + unknown_sender_policy: 'public', + created_at: now(), + }); + const { session: paDiscordSession } = resolveSession('ag-pa', 'mg-discord', null, 'shared'); + + // PA sends from Slack + await routeAgentMessage( + { id: 'out-fwd', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'research' }), in_reply_to: null }, + paSlackSession, + ); + + // Researcher responds back to PA + const { getSessionsByAgentGroup } = await import('./db/sessions.js'); + const researcherSession = getSessionsByAgentGroup('ag-researcher')[0]; + + await routeAgentMessage( + { id: 'out-reply', platform_id: 'ag-pa', content: JSON.stringify({ text: 'found it' }), in_reply_to: null }, + researcherSession, + ); + + const slackDb = new Database(inboundDbPath('ag-pa', paSlackSession.id)); + const slackA2a = slackDb.prepare("SELECT * FROM messages_in WHERE channel_type = 'agent'").all(); + slackDb.close(); + + const discordDb = new Database(inboundDbPath('ag-pa', paDiscordSession.id)); + const discordA2a = discordDb.prepare("SELECT * FROM messages_in WHERE channel_type = 'agent'").all(); + discordDb.close(); + + // Fixed: response lands in Slack (origin) not Discord (newest) + expect(slackA2a).toHaveLength(1); + expect(discordA2a).toHaveLength(0); + }); + + it('BUG: A2A-only session gets null session_routing (#2332)', async () => { + // Researcher only has an agent-shared session (no channel wiring). + // writeSessionRouting writes nulls because messaging_group_id is null. + const { routeAgentMessage } = await import('./modules/agent-to-agent/agent-route.js'); + + const { session: paSession } = resolveSession('ag-pa', 'mg-slack', null, 'shared'); + await routeAgentMessage( + { id: 'out-1', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'go' }), in_reply_to: null }, + paSession, + ); + + const { getSessionsByAgentGroup } = await import('./db/sessions.js'); + const researcherSessions = getSessionsByAgentGroup('ag-researcher'); + expect(researcherSessions).toHaveLength(1); + + writeSessionRouting('ag-researcher', researcherSessions[0].id); + + const rDb = new Database(inboundDbPath('ag-researcher', researcherSessions[0].id)); + const routing = rDb.prepare('SELECT channel_type, platform_id FROM session_routing WHERE id = 1').get() as + | { + channel_type: string | null; + platform_id: string | null; + } + | undefined; + rDb.close(); + + // BUG: session_routing is all null — researcher has no default routing + expect(routing).toBeDefined(); + expect(routing!.channel_type).toBeNull(); + expect(routing!.platform_id).toBeNull(); + }); +}); + describe('delivery', () => { it('should detect undelivered messages in outbound DB', () => { createAgentGroup({ diff --git a/src/host-sweep.test.ts b/src/host-sweep.test.ts index bd2e233..0249f4d 100644 --- a/src/host-sweep.test.ts +++ b/src/host-sweep.test.ts @@ -12,6 +12,7 @@ import { CLAIM_STUCK_MS, _resetStuckProcessingRowsForTesting, decideStuckAction, + parseSqliteUtc, } from './host-sweep.js'; import type { Session } from './types.js'; @@ -292,3 +293,44 @@ describe('resetStuckProcessingRows — orphan claim cleanup', () => { expect(row.tries).toBe(1); // not bumped, the skip path held }); }); + +describe('parseSqliteUtc', () => { + // Regression: SQLite TIMESTAMP strings have no zone marker, but Date.parse + // treats those as local time. On non-UTC hosts this made every claim look + // (TZ offset) hours stale and tripped kill-claim on freshly-claimed messages. + // The helper appends "Z" only when no marker is present, so parsing is + // always anchored to UTC regardless of host timezone. + + const utcMs = Date.parse('2026-04-20T12:00:00.000Z'); + + it('treats a SQLite-style timestamp (no zone) as UTC', () => { + expect(parseSqliteUtc('2026-04-20 12:00:00')).toBe(utcMs); + expect(parseSqliteUtc('2026-04-20T12:00:00')).toBe(utcMs); + expect(parseSqliteUtc('2026-04-20T12:00:00.000')).toBe(utcMs); + }); + + it('preserves an explicit Z marker', () => { + expect(parseSqliteUtc('2026-04-20T12:00:00.000Z')).toBe(utcMs); + expect(parseSqliteUtc('2026-04-20T12:00:00z')).toBe(utcMs); + }); + + it('preserves an explicit numeric offset', () => { + // 14:00+02:00 == 12:00 UTC + expect(parseSqliteUtc('2026-04-20T14:00:00+02:00')).toBe(utcMs); + expect(parseSqliteUtc('2026-04-20T14:00:00+0200')).toBe(utcMs); + // 07:00-05:00 == 12:00 UTC + expect(parseSqliteUtc('2026-04-20T07:00:00-05:00')).toBe(utcMs); + }); + + it('returns NaN for unparseable input', () => { + expect(Number.isNaN(parseSqliteUtc('not a date'))).toBe(true); + }); + + it('does not drift across host timezones for SQLite-style input', () => { + // The helper itself is timezone-independent because it forces UTC parsing. + // (Verifying the regex branch — without the helper, `Date.parse` of the + // bare string returns different values depending on the host TZ.) + const bare = '2026-04-20T12:00:00'; + expect(parseSqliteUtc(bare)).toBe(Date.parse(bare + 'Z')); + }); +}); diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 93a7e87..fbdd7e6 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -47,6 +47,17 @@ import { openInboundDb, openOutboundDb, openOutboundDbRw, inboundDbPath, heartbe import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js'; import type { Session } from './types.js'; +/** + * SQLite TIMESTAMP columns store UTC without a timezone marker. Date.parse + * treats timezoneless ISO strings as local time, so on non-UTC hosts every + * timestamp looks (TZ offset) hours stale — leading to spurious kill-claim + * decisions on freshly-claimed messages. Append "Z" when no zone marker is + * present so Date.parse interprets the string as UTC. + */ +export function parseSqliteUtc(s: string): number { + return Date.parse(/[zZ]|[+-]\d{2}:?\d{2}$/.test(s) ? s : s + 'Z'); +} + const SWEEP_INTERVAL_MS = 60_000; // Absolute idle ceiling for a running container. If the heartbeat file hasn't // been touched in this long, the container is either stuck or doing genuinely @@ -95,7 +106,7 @@ export function decideStuckAction(args: { const tolerance = Math.max(CLAIM_STUCK_MS, declaredBashMs ?? 0); for (const claim of claims) { - const claimedAt = Date.parse(claim.status_changed); + const claimedAt = parseSqliteUtc(claim.status_changed); if (Number.isNaN(claimedAt)) continue; const claimAge = now - claimedAt; if (claimAge <= tolerance) continue; @@ -275,7 +286,7 @@ function resetStuckProcessingRows( // Already rescheduled for a future retry — don't bump tries again. The // wake path (sweep step 2) will fire when process_after elapses and a // fresh container will clean the orphan claim on startup. - if (msg.processAfter && Date.parse(msg.processAfter) > now) continue; + if (msg.processAfter && parseSqliteUtc(msg.processAfter) > now) continue; if (msg.tries >= MAX_TRIES) { markMessageFailed(inDb, msg.id); diff --git a/src/modules/agent-to-agent/agent-route.test.ts b/src/modules/agent-to-agent/agent-route.test.ts index 4d48f6f..fca0d4b 100644 --- a/src/modules/agent-to-agent/agent-route.test.ts +++ b/src/modules/agent-to-agent/agent-route.test.ts @@ -1,20 +1,54 @@ -import { describe, expect, it } from 'vitest'; +import Database from 'better-sqlite3'; +import fs from 'fs'; +import path from 'path'; +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; -import { isSafeAttachmentName } from './agent-route.js'; +import { isSafeAttachmentName, routeAgentMessage } from './agent-route.js'; +import { createDestination } from './db/agent-destinations.js'; +import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js'; +import { createSession, updateSession } from '../../db/sessions.js'; +import { initSessionFolder, inboundDbPath, sessionDir, writeSessionMessage } from '../../session-manager.js'; +import type { Session } from '../../types.js'; + +vi.mock('../../container-runner.js', () => ({ + wakeContainer: vi.fn().mockResolvedValue(undefined), + isContainerRunning: vi.fn().mockReturnValue(false), + getActiveContainerCount: vi.fn().mockReturnValue(0), + killContainer: vi.fn(), +})); + +vi.mock('../../config.js', async () => { + const actual = await vi.importActual('../../config.js'); + return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-a2a-route' }; +}); + +const TEST_DIR = '/tmp/nanoclaw-test-a2a-route'; + +function now(): string { + return new Date().toISOString(); +} + +function readInbound(agentGroupId: string, sessionId: string) { + const db = new Database(inboundDbPath(agentGroupId, sessionId), { readonly: true }); + const rows = db + .prepare('SELECT id, platform_id, channel_type, content, source_session_id FROM messages_in ORDER BY seq') + .all() as Array<{ + id: string; + platform_id: string | null; + channel_type: string | null; + content: string; + source_session_id: string | null; + }>; + db.close(); + return rows; +} -/** - * `forwardAttachedFiles` has a filesystem side that's awkward to unit-test - * without mocking DATA_DIR. The guarantee worth pinning is that the - * filename validator rejects everything that could escape the inbox dir — - * `forwardAttachedFiles` runs this guard before any I/O, so traversal is - * impossible as long as this matrix holds. - */ describe('isSafeAttachmentName', () => { it('accepts plain filenames', () => { expect(isSafeAttachmentName('baby-duck.png')).toBe(true); expect(isSafeAttachmentName('file with spaces.pdf')).toBe(true); expect(isSafeAttachmentName('report.v2.docx')).toBe(true); - expect(isSafeAttachmentName('.hidden')).toBe(true); // leading dot is fine, just not `.` / `..` + expect(isSafeAttachmentName('.hidden')).toBe(true); }); it('rejects empty / sentinel values', () => { @@ -44,3 +78,359 @@ describe('isSafeAttachmentName', () => { expect(isSafeAttachmentName(undefined as unknown as string)).toBe(false); }); }); + +/** + * Return-path routing: when an a2a reply targets an agent group with multiple + * sessions, it must land in the *originating* session — not the newest one. + * + * Setup: agent A has two active sessions S1 (older) + S2 (newer). + * Agent B is the peer A talks to. Bidirectional destinations wired. + */ +describe('routeAgentMessage return-path', () => { + const A = 'ag-A'; + const B = 'ag-B'; + let S1: Session; + let S2: Session; + let SB: Session; + + beforeEach(() => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); + + const db = initTestDb(); + runMigrations(db); + + createAgentGroup({ id: A, name: 'A', folder: 'a', agent_provider: null, created_at: now() }); + createAgentGroup({ id: B, name: 'B', folder: 'b', agent_provider: null, created_at: now() }); + + // S1 (older), S2 (newer) — both active sessions on A. + S1 = { + id: 'sess-A-old', + agent_group_id: A, + messaging_group_id: null, + thread_id: null, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: null, + created_at: '2026-01-01T00:00:00.000Z', + }; + S2 = { + id: 'sess-A-new', + agent_group_id: A, + messaging_group_id: null, + thread_id: null, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: null, + created_at: '2026-02-01T00:00:00.000Z', + }; + SB = { + id: 'sess-B', + agent_group_id: B, + messaging_group_id: null, + thread_id: null, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: null, + created_at: '2026-01-15T00:00:00.000Z', + }; + createSession(S1); + createSession(S2); + createSession(SB); + initSessionFolder(A, S1.id); + initSessionFolder(A, S2.id); + initSessionFolder(B, SB.id); + + createDestination({ + agent_group_id: A, + local_name: 'b', + target_type: 'agent', + target_id: B, + created_at: now(), + }); + createDestination({ + agent_group_id: B, + local_name: 'a', + target_type: 'agent', + target_id: A, + created_at: now(), + }); + }); + + afterEach(() => { + closeDb(); + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + }); + + it('forward direction: stamps source_session_id on the target inbound row', async () => { + // A.S1 emits an outbound a2a to B. + await routeAgentMessage( + { + id: 'msg-from-A-S1', + platform_id: B, + content: JSON.stringify({ text: 'hello B' }), + in_reply_to: null, + }, + S1, + ); + + const bRows = readInbound(B, SB.id); + expect(bRows).toHaveLength(1); + expect(bRows[0].platform_id).toBe(A); + expect(bRows[0].source_session_id).toBe(S1.id); // <- the return address + }); + + it('reply direction: routes back to the originating session, not the newest', async () => { + // A.S1 sends to B. + await routeAgentMessage( + { + id: 'msg-from-A-S1', + platform_id: B, + content: JSON.stringify({ text: 'ping' }), + in_reply_to: null, + }, + S1, + ); + + // Capture the synthetic id the host stamped on B's inbound — that's what + // B's container would reference as `in_reply_to` when replying. + const bRows = readInbound(B, SB.id); + const yId = bRows[0].id; + + // B replies to that message. + await routeAgentMessage( + { + id: 'msg-from-B', + platform_id: A, + content: JSON.stringify({ text: 'pong' }), + in_reply_to: yId, + }, + SB, + ); + + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + + // The reply lands in S1 (originator) even though S2 is newer. + expect(s1Rows).toHaveLength(1); + expect(s1Rows[0].platform_id).toBe(B); + expect(JSON.parse(s1Rows[0].content).text).toBe('pong'); + expect(s2Rows).toHaveLength(0); + }); + + it('fallback: a2a with no in_reply_to falls through to newest-session lookup', async () => { + // No prior conversation. B initiates an a2a to A out of the blue. + await routeAgentMessage( + { + id: 'msg-from-B-fresh', + platform_id: A, + content: JSON.stringify({ text: 'unsolicited' }), + in_reply_to: null, + }, + SB, + ); + + // Newest session wins (current heuristic, preserved). + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + expect(s1Rows).toHaveLength(0); + expect(s2Rows).toHaveLength(1); + }); + + it('peer-affinity fallback: with no in_reply_to, routes to most recent peer-source session', async () => { + // A.S1 sends to B (establishing affinity: B's last contact from A was via S1). + await routeAgentMessage( + { + id: 'msg-from-A-S1-pre', + platform_id: B, + content: JSON.stringify({ text: 'context-establishing' }), + in_reply_to: null, + }, + S1, + ); + + // B sends a follow-up but its container forgot to set in_reply_to (e.g. + // emitted via an MCP tool path that doesn't thread the batch's in_reply_to + // through). The host should still route this to S1 because S1 is the + // session most recently in conversation with B — not the chronologically + // newest session of A. + await routeAgentMessage( + { + id: 'msg-from-B-followup', + platform_id: A, + content: JSON.stringify({ text: 'standing by' }), + in_reply_to: null, + }, + SB, + ); + + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + // Affinity wins: reply to S1, not the newer S2. + expect(s1Rows).toHaveLength(1); + expect(JSON.parse(s1Rows[0].content).text).toBe('standing by'); + expect(s2Rows).toHaveLength(0); + }); + + it('stale origin fallback: closed origin session falls through to newest active', async () => { + // A.S1 sends to B, establishing source_session_id = S1.id on B's inbound. + await routeAgentMessage( + { id: 'msg-fwd', platform_id: B, content: JSON.stringify({ text: 'hello' }), in_reply_to: null }, + S1, + ); + const bRows = readInbound(B, SB.id); + const inboundId = bRows[0].id; + + // Close S1 — simulates session cleanup or channel disconnect. + updateSession(S1.id, { status: 'closed' }); + + // B replies. origin points to S1 (closed), should fall through to S2. + await routeAgentMessage( + { id: 'msg-reply-stale', platform_id: A, content: JSON.stringify({ text: 'reply' }), in_reply_to: inboundId }, + SB, + ); + + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + expect(s1Rows).toHaveLength(0); + expect(s2Rows).toHaveLength(1); + }); + + it('cross-agent-group guard: origin session belonging to wrong agent group is rejected', async () => { + // Third agent group C sends to B, stamping source_session_id = SC on B's inbound. + const C = 'ag-C'; + createAgentGroup({ id: C, name: 'C', folder: 'c', agent_provider: null, created_at: now() }); + const SC: Session = { + id: 'sess-C', + agent_group_id: C, + messaging_group_id: null, + thread_id: null, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: null, + created_at: '2026-03-01T00:00:00.000Z', + }; + createSession(SC); + initSessionFolder(C, SC.id); + createDestination({ agent_group_id: C, local_name: 'b', target_type: 'agent', target_id: B, created_at: now() }); + + await routeAgentMessage( + { id: 'msg-from-C', platform_id: B, content: JSON.stringify({ text: 'from C' }), in_reply_to: null }, + SC, + ); + const bRows = readInbound(B, SB.id); + const cInboundId = bRows.find((r) => r.platform_id === C)!.id; + + // B replies to A, but in_reply_to references the C-originated row. + // Guard rejects (SC belongs to C, not A) → falls through to newest of A. + await routeAgentMessage( + { id: 'msg-reply-tamper', platform_id: A, content: JSON.stringify({ text: 'misdirected' }), in_reply_to: cInboundId }, + SB, + ); + + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + expect(s1Rows).toHaveLength(0); + expect(s2Rows).toHaveLength(1); + }); + + it('in_reply_to referencing a non-a2a row falls through to newest session', async () => { + // Write a channel message into B's inbound (no source_session_id). + writeSessionMessage(B, SB.id, { + id: 'channel-msg-1', + kind: 'chat', + timestamp: now(), + platformId: 'user-123', + channelType: 'slack', + threadId: null, + content: 'hello from slack', + }); + + // B replies to A with in_reply_to pointing to the channel message. + // source_session_id is null → peer-affinity finds nothing → newest of A. + await routeAgentMessage( + { id: 'msg-reply-channel', platform_id: A, content: JSON.stringify({ text: 'response' }), in_reply_to: 'channel-msg-1' }, + SB, + ); + + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + expect(s1Rows).toHaveLength(0); + expect(s2Rows).toHaveLength(1); + }); + + it('self-message is allowed without a destination row', async () => { + // A targets itself — no agent_destinations row exists for A→A. + await routeAgentMessage( + { id: 'self-msg', platform_id: A, content: JSON.stringify({ text: 'self-note' }), in_reply_to: null }, + S1, + ); + + // Lands in S2 (newest active session of A via resolveSession fallback). + const s2Rows = readInbound(A, S2.id); + expect(s2Rows).toHaveLength(1); + expect(JSON.parse(s2Rows[0].content).text).toBe('self-note'); + }); + + it('BUG: no volume cap on a2a routing — unbounded ping-pong is allowed (#2063)', async () => { + // Two agents can exchange unlimited messages with no rate limit or loop + // detection. This test documents the gap — it should FAIL once #2063 lands. + const errors: string[] = []; + for (let i = 0; i < 20; i++) { + try { + await routeAgentMessage( + { id: `ping-${i}`, platform_id: B, content: JSON.stringify({ text: `ping ${i}` }), in_reply_to: null }, + S1, + ); + await routeAgentMessage( + { id: `pong-${i}`, platform_id: A, content: JSON.stringify({ text: `pong ${i}` }), in_reply_to: null }, + SB, + ); + } catch (e) { + errors.push((e as Error).message); + break; + } + } + // BUG: all 40 messages go through — no cap, no throttle. + // Once loop prevention lands, this should throw or reject after a threshold. + const bRows = readInbound(B, SB.id); + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + expect(errors).toHaveLength(0); + expect(bRows).toHaveLength(20); + expect(s1Rows.length + s2Rows.length).toBe(20); + }); + + it('file forwarding: copies bytes from source outbox to target inbox', async () => { + // Place a file in S1's outbox for the message. + const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-with-file'); + fs.mkdirSync(outboxDir, { recursive: true }); + fs.writeFileSync(path.join(outboxDir, 'report.pdf'), 'fake-pdf-bytes'); + + await routeAgentMessage( + { + id: 'msg-with-file', + platform_id: B, + content: JSON.stringify({ text: 'see attached', files: ['report.pdf'] }), + in_reply_to: null, + }, + S1, + ); + + const bRows = readInbound(B, SB.id); + expect(bRows).toHaveLength(1); + const parsed = JSON.parse(bRows[0].content); + expect(parsed.attachments).toHaveLength(1); + expect(parsed.attachments[0].name).toBe('report.pdf'); + expect(parsed.attachments[0].type).toBe('file'); + + // Verify actual file bytes were copied to the target inbox. + const targetPath = path.join(sessionDir(B, SB.id), parsed.attachments[0].localPath); + expect(fs.existsSync(targetPath)).toBe(true); + expect(fs.readFileSync(targetPath, 'utf-8')).toBe('fake-pdf-bytes'); + }); +}); diff --git a/src/modules/agent-to-agent/agent-route.ts b/src/modules/agent-to-agent/agent-route.ts index 613a1ed..58e1419 100644 --- a/src/modules/agent-to-agent/agent-route.ts +++ b/src/modules/agent-to-agent/agent-route.ts @@ -23,10 +23,11 @@ import path from 'path'; import { isSafeAttachmentName } from '../../attachment-safety.js'; import { getAgentGroup } from '../../db/agent-groups.js'; +import { getInboundSourceSessionId, getMostRecentPeerSourceSessionId } from '../../db/session-db.js'; import { getSession } from '../../db/sessions.js'; import { wakeContainer } from '../../container-runner.js'; import { log } from '../../log.js'; -import { resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js'; +import { openInboundDb, resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js'; import type { Session } from '../../types.js'; import { hasDestination } from './db/agent-destinations.js'; @@ -101,6 +102,61 @@ export interface RoutableAgentMessage { id: string; platform_id: string | null; content: string; + /** + * For replies, the id of the inbound message being replied to. The + * container's formatter sets this from the first inbound in the batch + * (`container/agent-runner/src/formatter.ts`). Used here to route the + * reply back to the originating session — see `resolveTargetSession`. + */ + in_reply_to: string | null; +} + +/** + * Pick which session of `targetAgentGroupId` should receive this a2a message. + * + * Three layers, highest-fidelity first: + * + * 1. **Direct return-path** (in_reply_to lookup): if the message is a reply + * (`in_reply_to` set), open the source agent's inbound DB and read the + * triggering row's `source_session_id`. That column was stamped when the + * original outbound was routed — it's the session that started the + * conversation, and replies should land there even when the target has + * multiple active sessions. + * + * 2. **Peer-affinity fallback**: if (1) misses (in_reply_to is null or the + * referenced row isn't an a2a inbound), look up the most recent a2a + * inbound *from the target agent group* in source's inbound and use its + * `source_session_id`. The intuition: the last time this peer talked to + * me, which target session was driving? Route the reply there, since + * that's the session most plausibly in active conversation. + * + * 3. **Newest active session**: legacy heuristic. Used when no prior a2a + * has been recorded with `source_session_id` (e.g. fresh installs, + * pre-migration data). + */ +function resolveTargetSession(msg: RoutableAgentMessage, sourceSession: Session, targetAgentGroupId: string): Session { + const srcDb = openInboundDb(sourceSession.agent_group_id, sourceSession.id); + let originSessionId: string | null = null; + try { + if (msg.in_reply_to) { + originSessionId = getInboundSourceSessionId(srcDb, msg.in_reply_to); + } + if (!originSessionId) { + // Peer-affinity fallback — covers the case where the container's + // outbound write didn't carry in_reply_to (e.g. legacy MCP send_message + // path, container running pre-fix code). + originSessionId = getMostRecentPeerSourceSessionId(srcDb, targetAgentGroupId); + } + } finally { + srcDb.close(); + } + if (originSessionId) { + const candidate = getSession(originSessionId); + if (candidate && candidate.agent_group_id === targetAgentGroupId && candidate.status === 'active') { + return candidate; + } + } + return resolveSession(targetAgentGroupId, null, null, 'agent-shared').session; } export async function routeAgentMessage(msg: RoutableAgentMessage, session: Session): Promise { @@ -119,7 +175,7 @@ export async function routeAgentMessage(msg: RoutableAgentMessage, session: Sess if (!getAgentGroup(targetAgentGroupId)) { throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`); } - const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared'); + const targetSession = resolveTargetSession(msg, session, targetAgentGroupId); const a2aMsgId = `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; // If the source message references files (via `send_file`), forward the @@ -137,6 +193,7 @@ export async function routeAgentMessage(msg: RoutableAgentMessage, session: Sess channelType: 'agent', threadId: null, content: forwardedContent, + sourceSessionId: session.id, }); log.info('Agent message routed', { from: session.agent_group_id, diff --git a/src/session-manager.ts b/src/session-manager.ts index e3f3f7a..5c423ea 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -210,6 +210,12 @@ export function writeSessionMessage( * a trigger-1 message does arrive. */ trigger?: 0 | 1; + /** + * For agent-to-agent inbound: the source session id that emitted the + * outbound message which became this inbound row. Used as the return + * path so the target's reply routes back to that exact session. + */ + sourceSessionId?: string | null; }, ): void { // Extract base64 attachment data, save to inbox, replace with file paths @@ -228,6 +234,7 @@ export function writeSessionMessage( processAfter: message.processAfter ?? null, recurrence: message.recurrence ?? null, trigger: message.trigger ?? 1, + sourceSessionId: message.sourceSessionId ?? null, }); } finally { db.close(); diff --git a/vitest.config.ts b/vitest.config.ts index d961d1b..71afb78 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,6 @@ export default defineConfig({ test: { // container/agent-runner tests run under Bun (they depend on bun:sqlite). // See container/agent-runner/package.json "test" script. - include: ['src/**/*.test.ts', 'setup/**/*.test.ts'], + include: ['src/**/*.test.ts', 'setup/**/*.test.ts', 'scripts/**/*.test.ts'], }, });