Merge remote-tracking branch 'origin/main' into nc-cli
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';"
|
||||
```
|
||||
|
||||
@@ -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 <agent-id> --secret-ids <gmail-secret-id>
|
||||
GMAIL_IDS=$(onecli secrets list | jq -r '[.data[] | select(.name | test("(?i)gmail")) | .id] | join(",")')
|
||||
CURRENT=$(onecli agents secrets --id <agent-id> | jq -r '[.data[]] | join(",")')
|
||||
MERGED=$(printf '%s' "$CURRENT,$GMAIL_IDS" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||
onecli agents set-secrets --id <agent-id> --secret-ids "$MERGED"
|
||||
onecli agents secrets --id <agent-id>
|
||||
```
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
@@ -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('<cron-expr>', { 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',
|
||||
'<group_folder>',
|
||||
'<chat_jid>',
|
||||
'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',
|
||||
'<cron-expr>',
|
||||
'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
|
||||
```
|
||||
|
||||
208
.claude/skills/add-mnemon/SKILL.md
Normal file
208
.claude/skills/add-mnemon/SKILL.md
Normal file
@@ -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 `<system>` 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 <container> 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 <container> 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 <container> mnemon setup --target claude-code --yes --global
|
||||
```
|
||||
@@ -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='<FOLDER>';")
|
||||
AG_ID=$(pnpm exec tsx scripts/q.ts data/v2.db "SELECT id FROM agent_groups WHERE folder='<FOLDER>';")
|
||||
SETTINGS=data/v2-sessions/$AG_ID/.claude-shared/settings.json
|
||||
```
|
||||
|
||||
|
||||
@@ -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 <agent-id> --secret-ids <existing-ids>,<new-secret-id>
|
||||
```
|
||||
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=="<agentGroupId>") | .id')
|
||||
CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")')
|
||||
MERGED=$(printf '%s' "$CURRENT,<new-secret-id>" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||
onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED"
|
||||
onecli agents secrets --id "$AGENT_ID"
|
||||
```
|
||||
|
||||
#### Example: DeepSeek
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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 `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@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 `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@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
|
||||
|
||||
@@ -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-<id>.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-<id>.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:
|
||||
|
||||
@@ -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/<agent-group-id>/sessions/<session-id>/outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `<agent-group-id>` and `<session-id>` with the values from the script's output.
|
||||
- `pnpm exec tsx scripts/q.ts data/v2-sessions/<agent-group-id>/sessions/<session-id>/outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `<agent-group-id>` and `<session-id>` 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/<agent-group-id>/sessions/*/outbound.db` — confirm the session exists.
|
||||
|
||||
|
||||
@@ -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=="<agentGroupId>") | .id')
|
||||
CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")')
|
||||
MERGED=$(printf '%s' "$CURRENT,<new-secret-id>" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||
onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED"
|
||||
onecli agents secrets --id "$AGENT_ID"
|
||||
```
|
||||
|
||||
- `<agentGroupId>` — the `agentGroupId` field in `groups/<folder>/container.json`
|
||||
- `<new-secret-id>` — 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/<agent-group-id>/.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.
|
||||
|
||||
@@ -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 "<query>"
|
||||
```
|
||||
|
||||
```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**.
|
||||
|
||||
|
||||
@@ -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 <db> "<sql>"`. 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/<folder>/` | 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 |
|
||||
|-------|-------------|
|
||||
|
||||
@@ -215,3 +215,5 @@ See [CHANGELOG.md](CHANGELOG.md) for breaking changes, or the [full release hist
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=47894bd5-353b-42fe-bb97-74144e6df0bf" />
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
34
container/agent-runner/src/compact-instructions.ts
Normal file
34
container/agent-runner/src/compact-instructions.ts
Normal file
@@ -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:',
|
||||
' - <message from="..." sender="..." time="..."> for chat messages',
|
||||
' - <task from="..." time="..."> for scheduled tasks',
|
||||
' - <webhook from="..." source="..." event="..."> 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 <message to="name">...</message> blocks.',
|
||||
` Available destinations: ${names.length > 0 ? names.map((n) => `\`${n}\``).join(', ') : '(none)'}`,
|
||||
];
|
||||
|
||||
console.log(instructions.join('\n'));
|
||||
29
container/agent-runner/src/current-batch.ts
Normal file
29
container/agent-runner/src/current-batch.ts
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
63
container/agent-runner/src/destinations.test.ts
Normal file
63
container/agent-runner/src/destinations.test.ts
Normal file
@@ -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('<message to="name">');
|
||||
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('<message to="name">');
|
||||
expect(prompt).toContain('Default routing');
|
||||
expect(prompt).toContain('`casa`');
|
||||
});
|
||||
});
|
||||
@@ -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 `<internal>...</internal>`.',
|
||||
'',
|
||||
'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 `<message to="name">...</message>` block.');
|
||||
lines.push('**Every response must be wrapped** in a `<message to="name">...</message>` block.');
|
||||
lines.push('You can include multiple `<message>` blocks in one response to send to multiple destinations.');
|
||||
lines.push('Text outside of `<message>` blocks is scratchpad — logged but not sent anywhere.');
|
||||
lines.push('Use `<internal>...</internal>` 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 `<message>` 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.',
|
||||
);
|
||||
|
||||
@@ -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 `<message${idAttr}${fromAttr} sender="${escapeXml(sender)}" time="${escapeXml(time)}"${replyAttr}>${replyPrefix}${escapeXml(text)}${attachmentsSuffix}</message>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `<task${from} time="${escapeXml(time)}">${parts.join('\n')}</task>`;
|
||||
}
|
||||
|
||||
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 `<webhook${from} source="${escapeXml(source)}" event="${escapeXml(event)}">${JSON.stringify(content.payload || content, null, 2)}</webhook>`;
|
||||
}
|
||||
|
||||
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 `<system_response${from} action="${escapeXml(content.action || 'unknown')}" status="${escapeXml(content.status || 'unknown')}">${JSON.stringify(content.result || null)}</system_response>`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({}, () =>
|
||||
'<message to="discord-test">reply-d</message><message to="slack-test">reply-s</message>',
|
||||
);
|
||||
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 <message to="..."> 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(
|
||||
{},
|
||||
() => '<message to="nonexistent">dropped</message><message to="discord-test">delivered</message>',
|
||||
);
|
||||
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 <message> 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(
|
||||
{},
|
||||
() => '<message to="discord-test">for discord</message><message to="slack-test">for slack</message>',
|
||||
);
|
||||
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({}, () => '<message to="slack-new">hello slack</message>');
|
||||
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({}, () => '<message to="discord-test">reply</message>');
|
||||
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({}, () => '<message to="discord-test">Processed</message>');
|
||||
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(
|
||||
{},
|
||||
() => '<internal>thinking about this...</internal><message to="discord-test">answer</message><internal>done thinking</internal>',
|
||||
);
|
||||
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({}, () => '<message to="discord-test">done</message>');
|
||||
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 <message to="…"> 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('<message to="name">');
|
||||
|
||||
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<void>((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: '<message to="discord-test">ack</message>' };
|
||||
while (!ended && !aborted) {
|
||||
await new Promise<void>((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<void> {
|
||||
return Promise.race([
|
||||
@@ -119,3 +430,142 @@ async function waitFor(condition: () => boolean, timeoutMs: number): Promise<voi
|
||||
function sleep(ms: number): Promise<void> {
|
||||
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({}, () => '<message to="discord-test">should not run</message>');
|
||||
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');
|
||||
})(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
50
container/agent-runner/src/mcp-tools/core.test.ts
Normal file
50
container/agent-runner/src/mcp-tools/core.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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('<task');
|
||||
expect(prompt).toContain('Review open PRs');
|
||||
});
|
||||
|
||||
@@ -55,15 +55,17 @@ describe('formatter', () => {
|
||||
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('<webhook');
|
||||
expect(prompt).toContain('source="github"');
|
||||
expect(prompt).toContain('event="push"');
|
||||
});
|
||||
|
||||
it('should format system messages', () => {
|
||||
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('<system_response');
|
||||
expect(prompt).toContain('action="register_group"');
|
||||
});
|
||||
|
||||
it('should handle mixed kinds', () => {
|
||||
@@ -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('<system_response');
|
||||
});
|
||||
|
||||
it('should escape XML in content', () => {
|
||||
@@ -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('<task');
|
||||
expect(prompt).toContain('from="slack-ops"');
|
||||
});
|
||||
|
||||
it('task message omits from= when routing is null', () => {
|
||||
insertMessage('t1', 'task', { prompt: 'check status' });
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).toContain('<task');
|
||||
expect(prompt).not.toContain('from=');
|
||||
});
|
||||
|
||||
it('webhook message includes from= when destination matches', () => {
|
||||
seedDestination('github-ch', 'github', 'repo-1');
|
||||
insertWithRouting('w1', 'webhook', { source: 'github', event: 'push', payload: {} }, 'github', 'repo-1');
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).toContain('<webhook');
|
||||
expect(prompt).toContain('from="github-ch"');
|
||||
});
|
||||
|
||||
it('system message includes from= when destination matches', () => {
|
||||
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('<system_response');
|
||||
expect(prompt).toContain('from="discord-main"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mock provider', () => {
|
||||
it('should produce init + result events', async () => {
|
||||
const provider = new MockProvider({}, (prompt) => `Echo: ${prompt}`);
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<void> {
|
||||
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 `<message to="…">` 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 <message to="name"> 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 <message to="name">...</message> blocks
|
||||
* and dispatch each one to its resolved destination. Text outside of blocks
|
||||
* (including <internal>...</internal>) is normally scratchpad — logged but
|
||||
* not sent.
|
||||
* (including <internal>...</internal>) is scratchpad — logged but not sent.
|
||||
*
|
||||
* Single-destination shortcut: if the agent has exactly one configured
|
||||
* destination AND the output contains zero <message> blocks, the entire
|
||||
* cleaned text (with <internal> 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 <message to="name">...</message>
|
||||
* blocks, even with a single destination. Bare text is scratchpad only.
|
||||
*/
|
||||
function dispatchResultText(text: string, routing: RoutingContext): void {
|
||||
const MESSAGE_RE = /<message\s+to="([^"]+)"\s*>([\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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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 `<message to="…">` 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 };
|
||||
|
||||
85
container/skills/onecli-gateway/SKILL.md
Normal file
85
container/skills/onecli-gateway/SKILL.md
Normal file
@@ -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.
|
||||
7
container/skills/onecli-gateway/instructions.md
Normal file
7
container/skills/onecli-gateway/instructions.md
Normal file
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -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': {}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="141k tokens, 71% of context window">
|
||||
<title>141k tokens, 71% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="150k tokens, 75% of context window">
|
||||
<title>150k tokens, 75% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">141k</text>
|
||||
<text x="71" y="14">141k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">150k</text>
|
||||
<text x="71" y="14">150k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
106
scripts/q.test.ts
Normal file
106
scripts/q.test.ts
Normal file
@@ -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/);
|
||||
});
|
||||
});
|
||||
58
scripts/q.ts
Normal file
58
scripts/q.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* scripts/q.ts — sqlite3 CLI replacement for skill SQL invocations.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx scripts/q.ts <db-path> "<sql>"
|
||||
*
|
||||
* 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 <db-path> "<sql>"');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
try {
|
||||
const stmt = db.prepare(sql);
|
||||
if (stmt.reader) {
|
||||
const rows = stmt.all() as Record<string, unknown>[];
|
||||
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();
|
||||
}
|
||||
@@ -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<void> {
|
||||
} 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<void> {
|
||||
} 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<void> {
|
||||
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<void> {
|
||||
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<ChannelChoice> {
|
||||
return choice;
|
||||
}
|
||||
|
||||
async function askOtherChannelName(): Promise<void> {
|
||||
async function askOtherChannelName(): Promise<void | typeof BACK_TO_CHANNEL_SELECTION> {
|
||||
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',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -290,7 +290,8 @@ async function askOperatorHandle(): Promise<string> {
|
||||
"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',
|
||||
|
||||
@@ -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<ChannelFlowR
|
||||
}
|
||||
|
||||
async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
|
||||
// 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',
|
||||
);
|
||||
|
||||
|
||||
@@ -95,12 +95,25 @@ export async function runTeamsChannel(_displayName: string): Promise<ChannelFlow
|
||||
const prereqsResult = await confirmPrereqs({ collected, completed });
|
||||
if (prereqsResult === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
await stepPublicUrl({ collected, completed });
|
||||
await stepAppRegistration({ collected, completed });
|
||||
await stepClientSecret({ collected, completed });
|
||||
await stepAzureBot({ collected, completed });
|
||||
await stepEnableTeamsChannel({ collected, completed });
|
||||
if (await stepAppRegistration({ collected, completed }) === 'back') {
|
||||
return BACK_TO_CHANNEL_SELECTION;
|
||||
}
|
||||
if (await stepClientSecret({ collected, completed }) === 'back') {
|
||||
return BACK_TO_CHANNEL_SELECTION;
|
||||
}
|
||||
if (await stepAzureBot({ collected, completed }) === 'back') {
|
||||
return BACK_TO_CHANNEL_SELECTION;
|
||||
}
|
||||
if (await stepEnableTeamsChannel({ collected, completed }) === 'back') {
|
||||
return BACK_TO_CHANNEL_SELECTION;
|
||||
}
|
||||
const manifestResult = await stepGenerateManifest({ collected, completed });
|
||||
await stepSideload({ collected, completed, zipPath: manifestResult.zipPath });
|
||||
if (
|
||||
await stepSideload({ collected, completed, zipPath: manifestResult.zipPath })
|
||||
=== 'back'
|
||||
) {
|
||||
return BACK_TO_CHANNEL_SELECTION;
|
||||
}
|
||||
|
||||
await installAdapter(collected);
|
||||
completed.push('Adapter installed and service restarted.');
|
||||
@@ -229,7 +242,7 @@ async function stepPublicUrl(args: { collected: Collected; completed: string[] }
|
||||
async function stepAppRegistration(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<void> {
|
||||
}): 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<void> {
|
||||
}): 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<void> {
|
||||
}): 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<void> {
|
||||
}): 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<void> {
|
||||
}): 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<void> | Promise<unknown>;
|
||||
reshow: () => Promise<'continue' | 'back'>;
|
||||
args: { collected: Collected; completed: string[] };
|
||||
}): Promise<void> {
|
||||
}): 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string[]> = {
|
||||
export const STEP_FILES: Record<string, string[]> = {
|
||||
bootstrap: ['setup.sh', 'setup/install-node.sh', 'nanoclaw.sh'],
|
||||
environment: ['setup/environment.ts'],
|
||||
container: [
|
||||
@@ -81,7 +81,7 @@ const STEP_FILES: Record<string, string[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
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<boolean> {
|
||||
export async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
|
||||
if (!isClaudeInstalled()) {
|
||||
const install = ensureAnswer(
|
||||
await p.confirm({
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean>((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');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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<unknown> | 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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user