Enhance workspace path security and metrics tracking

- Introduced `workspace_root` configuration in global and MagicForm settings to enforce path security.
- Updated CLI commands to require relative paths for `--workspace` and `--config-dir` when `workspace_root` is set.
- Added detailed validation rules for workspace paths to prevent directory traversal and absolute paths.
- Enhanced agent processing to track execution metrics, including token usage and tool calls, and included these metrics in outbound messages.
- Updated callback payloads for MagicForm to support progress and escalation messages, including detailed metrics for final responses.
- Refactored agent loop to accumulate metrics across LLM iterations and publish progress updates during tool execution.
- Added tests to ensure proper handling of new metrics and workspace path validations.
This commit is contained in:
admin-mf 2026-03-06 16:33:50 -06:00
parent b7872e5423
commit 3572283d72
7 changed files with 503 additions and 57 deletions

View file

@ -0,0 +1,86 @@
{
"permissions": {
"allow": [
"Bash(git add pkg/tools/web.go)",
"Bash(git add pkg/tools/filesystem.go)",
"Bash(go build ./pkg/skills/...)",
"Bash(git add pkg/skills/installer.go)",
"Bash(go build ./cmd/picoclaw-launcher/...)",
"Bash(git add cmd/picoclaw-launcher/internal/server/auth_handlers.go)",
"Bash(git add cmd/picoclaw-launcher/internal/server/server.go)",
"Bash(git add cmd/picoclaw-launcher/internal/server/server.go cmd/picoclaw-launcher/main.go)",
"Bash(go test ./pkg/skills/...)",
"Bash(git add pkg/skills/clawhub_registry.go)",
"Bash(git add pkg/skills/loader.go pkg/skills/loader_test.go)",
"Bash(go build ./pkg/skills/... ./pkg/tools/...)",
"Bash(git add pkg/skills/registry.go pkg/skills/clawhub_registry.go pkg/tools/skills_install.go)",
"Bash(go build ./...)",
"Bash(go test ./pkg/tools/ -run Shell -v -count=1)",
"Bash(go test ./pkg/tools/ -run \"DevNullAllowed|SafePathsInWorkspace\" -v -count=1)",
"Bash(git add pkg/config/config.go pkg/tools/shell.go)",
"Bash(grep -n \"func CreateProvider\" /c/src/picoclaw/pkg/providers/*.go)",
"Bash(grep -n \"func.*GetModelConfig\\\\|func.*WorkspacePath\" /c/src/picoclaw/pkg/config/*.go)",
"Bash(grep -n \"ConvertProvidersToModelList\" /c/src/picoclaw/pkg/config/*.go)",
"Bash(grep -n \"CreateProvider\" /c/src/picoclaw/cmd/*.go)",
"Bash(go build ./pkg/config/...)",
"Bash(go build ./cmd/picoclaw/internal/agent/...)",
"Bash(go build ./pkg/channels/magicform/...)",
"Bash(go run tmp_test_overlay.go)",
"Bash(go run tmp_test_raw.go)",
"Bash(go test ./pkg/config/... ./pkg/agent/... ./pkg/channels/magicform/...)",
"Bash(git stash)",
"Bash(go test ./pkg/config/... ./pkg/agent/...)",
"Bash(git stash pop)",
"Bash(git add cmd/picoclaw/internal/agent/command.go cmd/picoclaw/internal/agent/helpers.go pkg/agent/loop.go pkg/agent/bootstrap.go pkg/channels/magicform/magicform.go pkg/config/config.go pkg/tools/cron.go config/workspace.config.example.json)",
"Bash(git commit:*)",
"Bash(git rebase main)",
"Bash(python3 -c:*)",
"Bash(git add pkg/auth/oauth.go)",
"Bash(git rebase --continue)",
"Bash(git add pkg/agent/loop.go)",
"Bash(go build ./pkg/agent/)",
"Bash(git push -u origin cmdline)",
"Bash(gh pr:*)",
"Bash(go run github.com/daixiang0/gci@latest diff --section standard --section default --section \"prefix\\(github.com/sipeed/picoclaw\\)\" pkg/channels/magicform/magicform.go)",
"Bash(go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest)",
"Bash(golangci-lint fmt ./cmd/picoclaw/internal/gateway/ ./pkg/channels/magicform/ ./pkg/tools/shell.go ./pkg/tools/web.go ./cmd/picoclaw/internal/agent/ ./cmd/picoclaw-launcher/internal/server/ ./pkg/agent/loop.go ./pkg/auth/oauth.go)",
"Bash(golangci-lint fmt ./pkg/channels/magicform/ ./pkg/tools/ ./cmd/picoclaw/internal/agent/ ./cmd/picoclaw-launcher/internal/server/ ./pkg/agent/ ./pkg/auth/)",
"Bash(golangci-lint version)",
"Bash(golangci-lint fmt pkg/channels/magicform/magicform.go)",
"Bash(golangci-lint fmt \"c:\\\\src\\\\picoclaw\\\\pkg\\\\channels\\\\magicform\")",
"Bash(go install github.com/segmentio/golines@latest)",
"Bash(go install mvdan.cc/gofumpt@latest)",
"Bash(gofumpt -d cmd/picoclaw/internal/agent/helpers.go)",
"Bash(gofumpt -w cmd/picoclaw/internal/agent/helpers.go)",
"Bash(golines -m 120 -w cmd/picoclaw-launcher/internal/server/auth_handlers.go cmd/picoclaw-launcher/internal/server/server.go cmd/picoclaw/internal/agent/command.go pkg/agent/loop.go pkg/auth/oauth.go)",
"Bash(gofumpt -w pkg/channels/magicform/magicform.go pkg/channels/magicform/init.go pkg/tools/shell.go pkg/tools/web.go cmd/picoclaw/internal/gateway/helpers.go pkg/auth/oauth.go)",
"Bash(golines -m 120 -w pkg/channels/magicform/magicform.go pkg/tools/shell.go pkg/tools/web.go cmd/picoclaw/internal/gateway/helpers.go pkg/auth/oauth.go)",
"Bash(git add cmd/picoclaw-launcher/internal/server/auth_handlers.go cmd/picoclaw-launcher/internal/server/server.go cmd/picoclaw/internal/agent/command.go cmd/picoclaw/internal/agent/helpers.go cmd/picoclaw/internal/gateway/helpers.go pkg/agent/loop.go pkg/auth/oauth.go pkg/channels/magicform/init.go pkg/channels/magicform/magicform.go pkg/tools/shell.go pkg/tools/web.go)",
"Bash(git push)",
"Bash(git config --local -l)",
"Bash(xargs grep -l \"nuestra\\\\|fork\\\\|github.com/nuestra\\\\|github.com/sipeed\")",
"Bash(git check-ignore -v docs/magicform-integration.md)",
"Bash(xargs grep -l \"interface Channel\")",
"Bash(xargs grep -l \"filepath\\\\|validation\\\\|security\")",
"Bash(go test ./pkg/pathutil/...)",
"Bash(go test ./...)",
"Bash(go test ./pkg/pathutil/... ./pkg/config/... ./pkg/agent/... ./cmd/picoclaw/internal/agent/...)",
"Bash(go test ./pkg/config/...)",
"Bash(go test ./pkg/pathutil/... -v)",
"Bash(go test ./pkg/pathutil/... ./cmd/picoclaw/internal/agent/... ./pkg/config/... -v)",
"Bash(go test ./cmd/picoclaw/internal/agent/... -v -run TestValidateWorkspacePaths)",
"Bash(go test ./pkg/pathutil/... ./cmd/picoclaw/internal/agent/...)",
"Bash(go test ./pkg/pathutil/... ./cmd/picoclaw/internal/agent/... ./pkg/config/... ./pkg/agent/...)",
"Bash(git add cmd/picoclaw/internal/agent/helpers.go cmd/picoclaw/internal/agent/helpers_test.go config/config.example.json pkg/agent/loop.go pkg/channels/magicform/init.go pkg/channels/magicform/magicform.go pkg/config/config.go pkg/pathutil/resolve.go pkg/pathutil/resolve_test.go)",
"Bash(grep -rn \"^type Channel\\\\|^type.*Channel\" /c/src/picoclaw/pkg/channels/*.go)",
"Bash(go build ./pkg/bus/... ./pkg/agent/...)",
"Bash(go build ./pkg/bus/...)",
"Bash(go test ./pkg/bus/... ./pkg/agent/... ./pkg/channels/magicform/... ./cmd/picoclaw/internal/agent/... ./pkg/pathutil/...)",
"Bash(go test ./pkg/agent/...)",
"Bash(go test ./pkg/agent/ -run TestGlobalSkillFileContentChange -v -timeout 30s)",
"Bash(go test ./pkg/agent/)",
"Bash(go test ./pkg/bus/... ./pkg/channels/...)",
"Bash(go test ./pkg/agent/ -run 'Test[^G]' -v)"
]
}
}

View file

@ -22,6 +22,7 @@ Create `~/.picoclaw/config.json` (or set `PICOCLAW_CONFIG` env var):
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"workspace_root": "/data/workspaces",
"model_name": "main", "model_name": "main",
"max_tokens": 4096, "max_tokens": 4096,
"max_tool_iterations": 20 "max_tool_iterations": 20
@ -32,6 +33,8 @@ Create `~/.picoclaw/config.json` (or set `PICOCLAW_CONFIG` env var):
The global config is the base. Per-workspace configs overlay it (see [Workspace Config](#workspace-config)). The global config is the base. Per-workspace configs overlay it (see [Workspace Config](#workspace-config)).
`workspace_root` defines the security boundary for all workspace and config-dir paths. When set, `--workspace` and `--config-dir` must be relative subdirectories within it. See [Path Security](#path-security) for details.
--- ---
## Subcommands ## Subcommands
@ -61,8 +64,8 @@ Interact with the agent directly. Without `-m`, starts an interactive REPL. With
| `--message` | `-m` | | Single message (non-interactive). Omit for interactive REPL. | | `--message` | `-m` | | Single message (non-interactive). Omit for interactive REPL. |
| `--session` | `-s` | `""` (→ `agent:main:cli:default`) | Session key for conversation isolation (e.g. `stackId:conversationId`). | | `--session` | `-s` | `""` (→ `agent:main:cli:default`) | Session key for conversation isolation (e.g. `stackId:conversationId`). |
| `--model` | | | Override model name from config. | | `--model` | | | Override model name from config. |
| `--workspace` | | | Override agent workspace directory. | | `--workspace` | | | Agent workspace directory, relative to `workspace_root`. Resolved to an absolute path before use. |
| `--config-dir` | | | Directory containing `config.json` (model/agent/tool overrides) and bootstrap files (`AGENTS.md`, `IDENTITY.md`, `SOUL.md`, `USER.md`). | | `--config-dir` | | | Config directory (relative to `workspace_root`) containing `config.json` and bootstrap files (`AGENTS.md`, `IDENTITY.md`, `SOUL.md`, `USER.md`). |
| `--tools` | | | Comma-separated tool allowlist (e.g. `read_file,web_fetch`). Only these tools are enabled. | | `--tools` | | | Comma-separated tool allowlist (e.g. `read_file,web_fetch`). Only these tools are enabled. |
| `--skills` | | | Comma-separated skill filter (e.g. `summarize,translate`). Only these skills are loaded. | | `--skills` | | | Comma-separated skill filter (e.g. `summarize,translate`). Only these skills are loaded. |
| `--debug` | `-d` | `false` | Enable debug logging. | | `--debug` | `-d` | `false` | Enable debug logging. |
@ -98,11 +101,11 @@ picoclaw agent
# One-shot message # One-shot message
picoclaw agent -m "Hello, world" picoclaw agent -m "Hello, world"
# With workspace isolation # With workspace isolation (paths relative to workspace_root)
picoclaw agent -m "Summarize the report" \ picoclaw agent -m "Summarize the report" \
-s tenant1:conv42 \ -s tenant1:conv42 \
--workspace /data/workspaces/tenant1/conv42 \ --workspace tenant1/conv42 \
--config-dir /data/workspaces/tenant1/config --config-dir tenant1/config
# Restricted tools, custom model # Restricted tools, custom model
picoclaw agent -m "Search the web for recent news" \ picoclaw agent -m "Search the web for recent news" \
@ -113,6 +116,8 @@ picoclaw agent -m "Search the web for recent news" \
picoclaw agent -d -m "Hello" -s test picoclaw agent -d -m "Hello" -s test
``` ```
> **Note**: When `workspace_root` is set in the global config, `--workspace` and `--config-dir` must be relative paths (e.g. `tenant1/conv42`, not `/data/workspaces/tenant1/conv42`). When `workspace_root` is not set, paths fall back to `filepath.Abs` resolution for backward compatibility.
--- ---
## picoclaw gateway ## picoclaw gateway
@ -275,6 +280,8 @@ A workspace-level `config.json` (placed in the config directory) overlays the gl
**Not honored** (infrastructure-level): `gateway`, `heartbeat`, `devices`, `providers`, `channels`. **Not honored** (infrastructure-level): `gateway`, `heartbeat`, `devices`, `providers`, `channels`.
**Protected fields**: `workspace_root` cannot be overridden by a workspace config overlay — the boundary is always set by the base (global) config. If a workspace overlay sets a `workspace` value, it is validated against the base config's `workspace_root`; traversal attempts (e.g. `../../escape`) cause a hard error.
Example `config.json`: Example `config.json`:
```json ```json
@ -352,8 +359,38 @@ For `--tools` (CLI) or `allowedTools` (webhook API):
--- ---
## Path Security
When `workspace_root` is set in `agents.defaults`, all workspace and config-dir paths are validated as relative subdirectories of that root. This applies uniformly across the CLI (`--workspace`, `--config-dir`), the gateway (webhook `workspace`/`configDir` fields), and workspace config overlays.
**Validation rules** (when `workspace_root` is set):
- **Absolute paths are rejected** — use `tenant1/conv42`, not `/data/workspaces/tenant1/conv42`.
- **Directory traversal is rejected**`../escape`, `a/../../etc`, `foo/..` all fail.
- **Empty string and bare `.` are rejected** — the path must pick a subdirectory, not root itself.
- **Relative paths are joined to `workspace_root`**`tenant1/conv42` becomes `/data/workspaces/tenant1/conv42`.
- **A post-join boundary check** confirms the resolved path stays within `workspace_root`.
**Without `workspace_root`**: paths fall back to `filepath.Abs` resolution for backward compatibility, but traversal (`..`) is still rejected.
**Enforcement points**:
1. **CLI**`--workspace` and `--config-dir` are validated before any workspace config overlay is loaded.
2. **Gateway** — webhook `workspace` and `configDir` fields are validated by each channel handler (e.g. MagicForm).
3. **Agent loop** — defense-in-depth re-validation of metadata-driven workspace overrides.
4. **Config overlay**`workspace_root` cannot be overridden; `workspace` values from overlays are validated against the base config boundary.
---
## Troubleshooting ## Troubleshooting
**`--workspace` or `--config-dir` rejected**
- When `workspace_root` is set, paths must be relative subdirectories (e.g. `s1/c1`). Absolute paths, `..` traversal, and empty/`.` paths are rejected.
- If `workspace_root` is not set and you get a traversal error, the path contains `..` which is always blocked.
**Workspace config overlay rejected**
- A workspace `config.json` that sets `agents.defaults.workspace` to a path escaping `workspace_root` will cause a hard error during config merge. Fix the overlay's `workspace` value.
**Workspace config ignored** **Workspace config ignored**
- Verify `config.json` exists in the `--config-dir` path. - Verify `config.json` exists in the `--config-dir` path.
- Check that the JSON is valid (`picoclaw agent -d` shows parse errors). - Check that the JSON is valid (`picoclaw agent -d` shows parse errors).

View file

@ -44,6 +44,11 @@ Add to `~/.picoclaw/config.json`:
```jsonc ```jsonc
{ {
"agents": {
"defaults": {
"workspace_root": "/data/workspaces"
}
},
"gateway": { "gateway": {
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 18790 "port": 18790
@ -54,7 +59,6 @@ Add to `~/.picoclaw/config.json`:
"token": "your-shared-secret", "token": "your-shared-secret",
"backend_url": "https://api.magicform.example.com", "backend_url": "https://api.magicform.example.com",
"webhook_path": "/hooks/magicform", "webhook_path": "/hooks/magicform",
"workspace_root": "/data/workspaces",
"allow_from": [] "allow_from": []
} }
} }
@ -66,9 +70,16 @@ Add to `~/.picoclaw/config.json`:
| `token` | Bearer token for webhook auth. Empty = allow all (dev only). | `""` | | `token` | Bearer token for webhook auth. Empty = allow all (dev only). | `""` |
| `backend_url` | Fallback callback URL base (used when payload omits `callbackUrl`). | `""` | | `backend_url` | Fallback callback URL base (used when payload omits `callbackUrl`). | `""` |
| `webhook_path` | HTTP path for the webhook endpoint. | `/hooks/magicform` | | `webhook_path` | HTTP path for the webhook endpoint. | `/hooks/magicform` |
| `workspace_root` | Root directory for workspace/configDir path validation. Required for path security. | `""` | | `workspace_root` | Channel-level override for workspace path validation root. If not set, falls back to `agents.defaults.workspace_root`. At least one must be configured. | `""` |
| `allow_from` | Sender ID allowlist. Empty = allow all. Accepts strings and numbers (e.g. `["user1", 12345]`). | `[]` | | `allow_from` | Sender ID allowlist. Empty = allow all. Accepts strings and numbers (e.g. `["user1", 12345]`). | `[]` |
**`workspace_root` resolution**: The MagicForm channel determines its effective workspace root using:
1. `channels.magicform.workspace_root` (channel-level override), if set.
2. `agents.defaults.workspace_root` (global), if set.
3. If neither is configured, the gateway **fails to start** with an error. A workspace root is required for MagicForm because all webhook paths must be validated against a boundary.
The recommended approach is to set `workspace_root` once in `agents.defaults` so that it applies to both the CLI and all gateway channels. Use the channel-level `workspace_root` only if MagicForm needs a different root than other entry points.
All fields can be set via environment variables: All fields can be set via environment variables:
```bash ```bash
@ -78,6 +89,7 @@ PICOCLAW_CHANNELS_MAGICFORM_BACKEND_URL=https://api.magicform.example.com
PICOCLAW_CHANNELS_MAGICFORM_WEBHOOK_PATH=/hooks/magicform PICOCLAW_CHANNELS_MAGICFORM_WEBHOOK_PATH=/hooks/magicform
PICOCLAW_CHANNELS_MAGICFORM_WORKSPACE_ROOT=/data/workspaces PICOCLAW_CHANNELS_MAGICFORM_WORKSPACE_ROOT=/data/workspaces
PICOCLAW_CHANNELS_MAGICFORM_ALLOW_FROM=sender1,sender2 PICOCLAW_CHANNELS_MAGICFORM_ALLOW_FROM=sender1,sender2
PICOCLAW_AGENTS_DEFAULTS_WORKSPACE_ROOT=/data/workspaces
``` ```
### Start the gateway ### Start the gateway
@ -138,7 +150,12 @@ Content-Type: application/json
| `allowedTools` | string[] | No | Tool allowlist. Empty = all tools enabled. See [cli.md § Tool Names](cli.md#tool-names-reference). | | `allowedTools` | string[] | No | Tool allowlist. Empty = all tools enabled. See [cli.md § Tool Names](cli.md#tool-names-reference). |
| `allowedSkills` | string[] | No | Skill filter. Empty = all skills loaded. | | `allowedSkills` | string[] | No | Skill filter. Empty = all skills loaded. |
**Path security**: `workspace` and `configDir` must be relative paths that resolve under `workspace_root`. Traversal attempts (e.g. `../../etc`) are rejected with `400 Bad Request`. **Path security**: `workspace` and `configDir` must be relative paths that resolve to subdirectories under `workspace_root`. The following are rejected with `400 Bad Request`:
- Absolute paths (e.g. `/data/workspaces/s1/c1`)
- Directory traversal (`../escape`, `a/../../etc`, `foo/..`)
- Empty string or bare `.` (would resolve to root itself)
See [cli.md § Path Security](cli.md#path-security) for the full validation rules shared across CLI and gateway.
**Request size limit**: Webhook payloads are limited to 1 MB. Larger requests receive `413 Request Entity Too Large`. **Request size limit**: Webhook payloads are limited to 1 MB. Larger requests receive `413 Request Entity Too Large`.
@ -150,7 +167,7 @@ Returns `200 OK` immediately. Processing happens asynchronously.
### Callback: receive the result ### Callback: receive the result
PicoClaw POSTs the result to the callback URL: PicoClaw POSTs results back to the callback URL. There are three callback types: **final**, **progress**, and **escalation**.
``` ```
POST {callbackUrl} POST {callbackUrl}
@ -158,21 +175,116 @@ Authorization: Bearer your-shared-secret
Content-Type: application/json Content-Type: application/json
``` ```
```json #### Common fields
{
"stackId": "s1", Every callback includes these fields:
"conversationId": "c1",
"response": "Here is the summary of the latest sales report...",
"type": "final"
}
```
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `stackId` | string | Echoed from request. | | `stackId` | string | Echoed from request. |
| `conversationId` | string | Echoed from request. | | `conversationId` | string | Echoed from request. |
| `response` | string | The agent's response text. | | `taskId` | string | Unique task ID: `claw_task_{conversationId}_{createdAtMs}`. |
| `type` | string | Always `"final"`. | | `type` | string | `"final"`, `"progress"`, or `"escalation"`. |
| `status` | string | `"success"` or `"error"`. |
| `response` | string | The agent's response text (may be empty for progress/escalation). |
| `runtime` | string | Always `"picoclaw"`. |
#### Final callback
Sent once when the agent finishes processing. Includes execution metrics.
```json
{
"stackId": "s1",
"conversationId": "c1",
"taskId": "claw_task_c1_1709712000000",
"type": "final",
"status": "success",
"response": "Here is the summary of the latest sales report...",
"runtime": "picoclaw",
"durationMs": 4523,
"tokenUsage": {
"promptTokens": 1200,
"completionTokens": 350,
"totalTokens": 1550,
"model": "anthropic/claude-sonnet-4.6"
},
"toolCalls": 3,
"progress": null,
"escalation": null
}
```
| Field | Type | Description |
|-------|------|-------------|
| `durationMs` | number | Total processing time in milliseconds. |
| `tokenUsage` | object \| null | Token consumption breakdown. |
| `tokenUsage.promptTokens` | number | Prompt tokens used across all iterations. |
| `tokenUsage.completionTokens` | number | Completion tokens used across all iterations. |
| `tokenUsage.totalTokens` | number | Total tokens (prompt + completion). |
| `tokenUsage.model` | string | Model ID used for generation. |
| `toolCalls` | number | Total tool calls made during processing. |
| `error` | string | Error message (present only when `status` is `"error"`). |
| `progress` | null | Always null for final callbacks. |
| `escalation` | null | Always null for final callbacks. |
#### Progress callback
Sent during processing as the agent executes tools. Multiple progress callbacks may be sent before the final callback.
```json
{
"stackId": "s1",
"conversationId": "c1",
"taskId": "claw_task_c1_1709712000000",
"type": "progress",
"status": "success",
"response": "Running tools: web_fetch",
"runtime": "picoclaw",
"progress": {
"status": "thinking",
"toolName": "web_fetch",
"stepNumber": 2,
"message": "Running tools: web_fetch"
},
"escalation": null
}
```
| Field | Type | Description |
|-------|------|-------------|
| `progress` | object | Progress details. |
| `progress.status` | string | Current status (e.g. `"thinking"`). |
| `progress.toolName` | string | Name of the tool being executed. |
| `progress.stepNumber` | number | Current iteration number. |
| `progress.message` | string | Human-readable progress description. |
#### Escalation callback
Sent when the agent determines it needs human intervention.
```json
{
"stackId": "s1",
"conversationId": "c1",
"taskId": "claw_task_c1_1709712000000",
"type": "escalation",
"status": "success",
"response": "",
"runtime": "picoclaw",
"progress": null,
"escalation": {
"reason": "User requested human support",
"notes": "Customer issue requires billing system access"
}
}
```
| Field | Type | Description |
|-------|------|-------------|
| `escalation` | object | Escalation details. |
| `escalation.reason` | string | Why escalation is needed. |
| `escalation.notes` | string | Additional context (optional). |
### Session isolation ### Session isolation
@ -186,22 +298,32 @@ Sessions are stored at `{workspace}/sessions/`. Different conversations within t
MagicForm PicoClaw Gateway MagicForm PicoClaw Gateway
| | | |
|-- POST /hooks/magicform ----------->| |-- POST /hooks/magicform ----------->|
| validate token
| parse payload
| ResolveWorkspacePath(root, workspace) ← 400 on failure
| ResolveWorkspacePath(root, configDir) ← 400 on failure
|<------------ 200 OK ---------------| |<------------ 200 OK ---------------|
| | | |
| resolveWorkspace(workspace)
| resolveWorkspace(configDir)
| publish InboundMessage to bus | publish InboundMessage to bus
| | | |
| Agent loop: | Agent loop:
| re-validate workspace/configDir (defense-in-depth)
| create temp sessions + context | create temp sessions + context
| copyBootstrapFiles(configDir -> workspace) | copyBootstrapFiles(configDir -> workspace)
| loadWorkspaceConfig(configDir) | loadWorkspaceConfig(configDir)
| mergeWorkspaceConfig into cloned global cfg | mergeWorkspaceConfig into cloned global cfg
| createProvider (per-request) | createProvider (per-request)
| apply tool/skill filters | apply tool/skill filters
| run LLM iterations | run LLM iterations:
| for each iteration:
| LLM call → accumulate token usage
| tool calls → accumulate tool count
| | | |
|<-- POST callbackUrl (result) -------| |<-- POST callbackUrl (progress) ----| during tool execution
| |
| ... more iterations ...
| |
|<-- POST callbackUrl (final) -------| with metrics (duration, tokens, tool calls)
``` ```
--- ---
@ -211,11 +333,11 @@ MagicForm PicoClaw Gateway
You can test the same workspace/config setup without the gateway using `picoclaw agent`. See [cli.md § picoclaw agent](cli.md#picoclaw-agent) for full flag reference. You can test the same workspace/config setup without the gateway using `picoclaw agent`. See [cli.md § picoclaw agent](cli.md#picoclaw-agent) for full flag reference.
```bash ```bash
# One-shot with tenant isolation (same paths MagicForm would use) # One-shot with tenant isolation (same relative paths MagicForm would use)
picoclaw agent -m "Summarize the report" \ picoclaw agent -m "Summarize the report" \
-s s1:c1 \ -s s1:c1 \
--workspace /data/workspaces/s1/c1 \ --workspace s1/c1 \
--config-dir /data/workspaces/s1/config --config-dir s1/config
# Restricted tools, matching a webhook allowedTools filter # Restricted tools, matching a webhook allowedTools filter
picoclaw agent -m "Search the web for recent news" \ picoclaw agent -m "Search the web for recent news" \
@ -225,6 +347,8 @@ picoclaw agent -m "Search the web for recent news" \
picoclaw agent -d -m "Hello" -s test picoclaw agent -d -m "Hello" -s test
``` ```
> **Note**: The CLI resolves `--workspace` and `--config-dir` against `agents.defaults.workspace_root` using the same validation as the MagicForm webhook. Both entry points use the shared `pathutil.ResolveWorkspacePath` function.
--- ---
## Troubleshooting ## Troubleshooting
@ -235,19 +359,27 @@ picoclaw agent -d -m "Hello" -s test
**Webhook returns 401 Unauthorized** **Webhook returns 401 Unauthorized**
- Check that the `Authorization: Bearer {token}` header matches the `token` in the MagicForm channel config. Token comparison uses constant-time comparison. - Check that the `Authorization: Bearer {token}` header matches the `token` in the MagicForm channel config. Token comparison uses constant-time comparison.
**Webhook returns 400 "workspace path escapes workspace_root"** **Webhook returns 400 "Invalid workspace" or "Invalid configDir"**
- The `workspace` or `configDir` path in the payload resolves outside `workspace_root`. Ensure paths are relative (e.g. `s1/c1`, not `/data/workspaces/s1/c1`). - The `workspace` or `configDir` path failed validation. Common causes:
- **Absolute path** — use `s1/c1`, not `/data/workspaces/s1/c1`.
- **Traversal** — paths like `../escape` or `a/../../etc` are rejected.
- **Empty or `.`** — the path must be a subdirectory, not root itself.
- **Escapes root** — the resolved path lands outside `workspace_root`.
**Webhook returns 413 Request Entity Too Large** **Webhook returns 413 Request Entity Too Large**
- The request payload exceeds the 1 MB limit. Reduce the message size. - The request payload exceeds the 1 MB limit. Reduce the message size.
**Webhook returns 400 "workspace_root not configured"** **Gateway fails to start: "magicform channel requires workspace_root"**
- Set `workspace_root` in the MagicForm channel config. - Neither `channels.magicform.workspace_root` nor `agents.defaults.workspace_root` is configured. Set at least one. The recommended location is `agents.defaults.workspace_root`.
**Callback not received** **Callback not received**
- Check that `callbackUrl` in the payload or `backend_url` in config is reachable from PicoClaw. - Check that `callbackUrl` in the payload or `backend_url` in config is reachable from PicoClaw.
- Check PicoClaw logs for callback errors. - Check PicoClaw logs for callback errors.
- Request contexts expire after 10 minutes. - Request contexts expire after 10 minutes.
- Progress and escalation callbacks keep the request context alive. The final callback deletes it.
**Unexpected `null` in progress/escalation fields**
- `progress` and `escalation` are always present in every callback but set to `null` when not applicable (e.g. `progress` is `null` in a final callback). Check the `type` field to determine which sub-object to read.
**Session not persisting across requests** **Session not persisting across requests**
- Ensure the same `workspace` path is sent for the same conversation. - Ensure the same `workspace` path is sent for the same conversation.

View file

@ -349,9 +349,10 @@ func (al *AgentLoop) Run(ctx context.Context) error {
// } // }
// }() // }()
response, err := al.processMessage(ctx, msg) response, metrics, err := al.processMessage(ctx, msg)
if err != nil { if err != nil {
response = fmt.Sprintf("Error processing message: %v", err) response = fmt.Sprintf("Error processing message: %v", err)
metrics = nil // don't attach metrics to error responses
} }
if response != "" { if response != "" {
@ -373,6 +374,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
Channel: msg.Channel, Channel: msg.Channel,
ChatID: msg.ChatID, ChatID: msg.ChatID,
Content: response, Content: response,
Metrics: metrics,
}) })
logger.InfoCF("agent", "Published outbound response", logger.InfoCF("agent", "Published outbound response",
map[string]any{ map[string]any{
@ -548,7 +550,8 @@ func (al *AgentLoop) ProcessDirectWithChannel(
SessionKey: sessionKey, SessionKey: sessionKey,
} }
return al.processMessage(ctx, msg) result, _, err := al.processMessage(ctx, msg)
return result, err
} }
// ProcessHeartbeat processes a heartbeat request without session history. // ProcessHeartbeat processes a heartbeat request without session history.
@ -561,7 +564,7 @@ func (al *AgentLoop) ProcessHeartbeat(
if agent == nil { if agent == nil {
return "", fmt.Errorf("no default agent for heartbeat") return "", fmt.Errorf("no default agent for heartbeat")
} }
return al.runAgentLoop(ctx, agent, processOptions{ result, _, err := al.runAgentLoop(ctx, agent, processOptions{
SessionKey: "heartbeat", SessionKey: "heartbeat",
Channel: channel, Channel: channel,
ChatID: chatID, ChatID: chatID,
@ -571,9 +574,10 @@ func (al *AgentLoop) ProcessHeartbeat(
SendResponse: false, SendResponse: false,
NoHistory: true, // Don't load session history for heartbeat NoHistory: true, // Don't load session history for heartbeat
}) })
return result, err
} }
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, *bus.ResponseMetrics, error) {
// Add message preview to log (show full content for error messages) // Add message preview to log (show full content for error messages)
var logContent string var logContent string
if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") { if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") {
@ -596,7 +600,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
// Route system messages to processSystemMessage // Route system messages to processSystemMessage
if msg.Channel == "system" { if msg.Channel == "system" {
return al.processSystemMessage(ctx, msg) result, err := al.processSystemMessage(ctx, msg)
return result, nil, err
} }
route, agent, routeErr := al.resolveMessageRoute(msg) route, agent, routeErr := al.resolveMessageRoute(msg)
@ -606,11 +611,11 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
// context-dependent commands check their own Runtime fields and report // context-dependent commands check their own Runtime fields and report
// "unavailable" when the required capability is nil. // "unavailable" when the required capability is nil.
if response, handled := al.handleCommand(ctx, msg, agent); handled { if response, handled := al.handleCommand(ctx, msg, agent); handled {
return response, nil return response, nil, nil
} }
if routeErr != nil { if routeErr != nil {
return "", routeErr return "", nil, routeErr
} }
// Reset message-tool state for this round so we don't skip publishing due to a previous round. // Reset message-tool state for this round so we don't skip publishing due to a previous round.
@ -646,7 +651,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
if err != nil { if err != nil {
logger.WarnCF("agent", "Rejecting workspace_override from metadata", logger.WarnCF("agent", "Rejecting workspace_override from metadata",
map[string]any{"workspace_override": workspaceOverride, "error": err.Error()}) map[string]any{"workspace_override": workspaceOverride, "error": err.Error()})
return "", fmt.Errorf("invalid workspace_override in metadata: %w", err) return "", nil, fmt.Errorf("invalid workspace_override in metadata: %w", err)
} }
workspaceOverride = resolved workspaceOverride = resolved
} }
@ -655,7 +660,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
if err != nil { if err != nil {
logger.WarnCF("agent", "Rejecting config_dir from metadata", logger.WarnCF("agent", "Rejecting config_dir from metadata",
map[string]any{"config_dir": configDir, "error": err.Error()}) map[string]any{"config_dir": configDir, "error": err.Error()})
return "", fmt.Errorf("invalid config_dir in metadata: %w", err) return "", nil, fmt.Errorf("invalid config_dir in metadata: %w", err)
} }
configDir = resolved configDir = resolved
} }
@ -774,7 +779,7 @@ func (al *AgentLoop) processSystemMessage(
// Use the origin session for context // Use the origin session for context
sessionKey := routing.BuildAgentMainSessionKey(agent.ID) sessionKey := routing.BuildAgentMainSessionKey(agent.ID)
return al.runAgentLoop(ctx, agent, processOptions{ result, _, err := al.runAgentLoop(ctx, agent, processOptions{
SessionKey: sessionKey, SessionKey: sessionKey,
Channel: originChannel, Channel: originChannel,
ChatID: originChatID, ChatID: originChatID,
@ -783,6 +788,7 @@ func (al *AgentLoop) processSystemMessage(
EnableSummary: false, EnableSummary: false,
SendResponse: true, SendResponse: true,
}) })
return result, err
} }
// runAgentLoop is the core message processing logic. // runAgentLoop is the core message processing logic.
@ -790,7 +796,7 @@ func (al *AgentLoop) runAgentLoop(
ctx context.Context, ctx context.Context,
agent *AgentInstance, agent *AgentInstance,
opts processOptions, opts processOptions,
) (string, error) { ) (string, *bus.ResponseMetrics, error) {
// 0. Record last channel for heartbeat notifications (skip internal channels) // 0. Record last channel for heartbeat notifications (skip internal channels)
if opts.Channel != "" && opts.ChatID != "" { if opts.Channel != "" && opts.ChatID != "" {
// Don't record internal channels (cli, system, subagent) // Don't record internal channels (cli, system, subagent)
@ -836,7 +842,7 @@ func (al *AgentLoop) runAgentLoop(
} else if wc != nil { } else if wc != nil {
tmpCfg := al.cfg.Clone() tmpCfg := al.cfg.Clone()
if err := tmpCfg.MergeWorkspaceConfig(wc); err != nil { if err := tmpCfg.MergeWorkspaceConfig(wc); err != nil {
return "", fmt.Errorf("workspace config overlay rejected: %w", err) return "", nil, fmt.Errorf("workspace config overlay rejected: %w", err)
} }
if tmpCfg.Agents.Defaults.GetModelName() == "" { if tmpCfg.Agents.Defaults.GetModelName() == "" {
tmpCfg.Agents.Defaults.ModelName = agent.Model tmpCfg.Agents.Defaults.ModelName = agent.Model
@ -887,9 +893,30 @@ func (al *AgentLoop) runAgentLoop(
opts.effContextBuilder = effContextBuilder opts.effContextBuilder = effContextBuilder
// 3. Run LLM iteration loop // 3. Run LLM iteration loop
finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts) startTime := time.Now()
finalContent, iteration, iterMetrics, err := al.runLLMIteration(ctx, agent, messages, opts)
durationMs := time.Since(startTime).Milliseconds()
if err != nil { if err != nil {
return "", err return "", nil, err
}
// Build response metrics from accumulated iteration metrics.
var respMetrics *bus.ResponseMetrics
if iterMetrics != nil {
respMetrics = &bus.ResponseMetrics{
DurationMs: durationMs,
ToolCalls: iterMetrics.ToolCalls,
Iterations: iteration,
Model: iterMetrics.Model,
}
if iterMetrics.PromptTokens > 0 || iterMetrics.CompletionTokens > 0 {
respMetrics.TokenUsage = &bus.TokenUsage{
PromptTokens: iterMetrics.PromptTokens,
CompletionTokens: iterMetrics.CompletionTokens,
TotalTokens: iterMetrics.PromptTokens + iterMetrics.CompletionTokens,
Model: iterMetrics.Model,
}
}
} }
// If last tool had ForUser content and we already sent it, we might not need to send final response // If last tool had ForUser content and we already sent it, we might not need to send final response
@ -917,12 +944,13 @@ func (al *AgentLoop) runAgentLoop(
) )
} }
// 7. Optional: send response via bus // 7. Optional: send response via bus (with metrics on the final message)
if opts.SendResponse { if opts.SendResponse {
al.bus.PublishOutbound(ctx, bus.OutboundMessage{ al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: opts.Channel, Channel: opts.Channel,
ChatID: opts.ChatID, ChatID: opts.ChatID,
Content: finalContent, Content: finalContent,
Metrics: respMetrics,
}) })
} }
@ -934,9 +962,10 @@ func (al *AgentLoop) runAgentLoop(
"session_key": opts.SessionKey, "session_key": opts.SessionKey,
"iterations": iteration, "iterations": iteration,
"final_length": len(finalContent), "final_length": len(finalContent),
"duration_ms": durationMs,
}) })
return finalContent, nil return finalContent, respMetrics, nil
} }
func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) { func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) {
@ -995,15 +1024,26 @@ func (al *AgentLoop) handleReasoning(
} }
} }
// turnMetrics accumulates cumulative totals across all LLM iterations within
// a single agent processing turn. Values are summed after each iteration in
// runLLMIteration, not per-iteration snapshots.
type turnMetrics struct {
PromptTokens int
CompletionTokens int
ToolCalls int
Model string // last model used
}
// runLLMIteration executes the LLM call loop with tool handling. // runLLMIteration executes the LLM call loop with tool handling.
func (al *AgentLoop) runLLMIteration( func (al *AgentLoop) runLLMIteration(
ctx context.Context, ctx context.Context,
agent *AgentInstance, agent *AgentInstance,
messages []providers.Message, messages []providers.Message,
opts processOptions, opts processOptions,
) (string, int, error) { ) (string, int, *turnMetrics, error) {
iteration := 0 iteration := 0
var finalContent string var finalContent string
metrics := &turnMetrics{}
// Resolve effective provider/model — workspace config overrides win // Resolve effective provider/model — workspace config overrides win
effProvider := agent.Provider effProvider := agent.Provider
@ -1200,7 +1240,7 @@ func (al *AgentLoop) runLLMIteration(
"iteration": iteration, "iteration": iteration,
"error": err.Error(), "error": err.Error(),
}) })
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err) return "", iteration, metrics, fmt.Errorf("LLM call failed after retries: %w", err)
} }
go al.handleReasoning( go al.handleReasoning(
@ -1220,6 +1260,15 @@ func (al *AgentLoop) runLLMIteration(
"target_channel": al.targetReasoningChannelID(opts.Channel), "target_channel": al.targetReasoningChannelID(opts.Channel),
"channel": opts.Channel, "channel": opts.Channel,
}) })
// Accumulate token usage and tool call counts across iterations.
if response.Usage != nil {
metrics.PromptTokens += response.Usage.PromptTokens
metrics.CompletionTokens += response.Usage.CompletionTokens
}
metrics.ToolCalls += len(response.ToolCalls)
metrics.Model = activeModel
// Check if no tool calls - we're done // Check if no tool calls - we're done
if len(response.ToolCalls) == 0 { if len(response.ToolCalls) == 0 {
finalContent = response.Content finalContent = response.Content
@ -1283,6 +1332,24 @@ func (al *AgentLoop) runLLMIteration(
// Save assistant message with tool calls to session // Save assistant message with tool calls to session
opts.effSessions.AddFullMessage(opts.SessionKey, assistantMsg) opts.effSessions.AddFullMessage(opts.SessionKey, assistantMsg)
// Publish progress callback for channels that support it (e.g. MagicForm).
// Non-internal channels receive a progress update before tool execution.
if !constants.IsInternalChannel(opts.Channel) && opts.Channel != "" {
toolNamesList := strings.Join(toolNames, ", ")
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Type: bus.MessageTypeProgress,
Content: fmt.Sprintf("Running tools: %s", toolNamesList),
Progress: &bus.OutboundProgress{
Status: "thinking",
ToolName: toolNames[0],
StepNumber: iteration,
Message: fmt.Sprintf("Running tools: %s", toolNamesList),
},
})
}
// Execute tool calls in parallel // Execute tool calls in parallel
type indexedAgentResult struct { type indexedAgentResult struct {
result *tools.ToolResult result *tools.ToolResult
@ -1405,7 +1472,7 @@ func (al *AgentLoop) runLLMIteration(
} }
} }
return finalContent, iteration, nil return finalContent, iteration, metrics, nil
} }
// selectCandidates returns the model candidates and resolved model name to use // selectCandidates returns the model candidates and resolved model name to use

View file

@ -374,7 +374,7 @@ func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, ms
timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout) timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout)
defer cancel() defer cancel()
response, err := h.al.processMessage(timeoutCtx, msg) response, _, err := h.al.processMessage(timeoutCtx, msg)
if err != nil { if err != nil {
tb.Fatalf("processMessage failed: %v", err) tb.Fatalf("processMessage failed: %v", err)
} }

View file

@ -29,10 +29,66 @@ type InboundMessage struct {
Metadata map[string]string `json:"metadata,omitempty"` Metadata map[string]string `json:"metadata,omitempty"`
} }
// Outbound message type constants identify the kind of outbound message.
// Channels that don't support progress/escalation treat all messages as content.
const (
MessageTypeFinal = "" // default: final response
MessageTypeProgress = "progress" // intermediate progress update
MessageTypeEscalation = "escalation" // needs human intervention
)
type OutboundMessage struct { type OutboundMessage struct {
Channel string `json:"channel"` Channel string `json:"channel"`
ChatID string `json:"chat_id"` ChatID string `json:"chat_id"`
Content string `json:"content"` Content string `json:"content"`
// Type distinguishes final responses from progress/escalation updates.
// Empty string (default) means final response. Channels that don't support
// progress callbacks ignore this field and treat all messages as content.
Type string `json:"type,omitempty"`
// Metrics is populated by the agent loop for the final response message.
// Channels that support rich callbacks (e.g. MagicForm) use this to include
// execution metrics in their response payloads. Nil for intermediate messages.
Metrics *ResponseMetrics `json:"metrics,omitempty"`
// Progress is populated for Type="progress" messages.
Progress *OutboundProgress `json:"progress,omitempty"`
// Escalation is populated for Type="escalation" messages.
Escalation *OutboundEscalation `json:"escalation,omitempty"`
}
// OutboundProgress carries progress update details.
type OutboundProgress struct {
Status string `json:"status"` // e.g. "thinking"
ToolName string `json:"toolName,omitempty"`
StepNumber int `json:"stepNumber,omitempty"`
Message string `json:"message,omitempty"`
}
// OutboundEscalation carries escalation details.
type OutboundEscalation struct {
Reason string `json:"reason"`
Notes string `json:"notes,omitempty"`
}
// TokenUsage tracks LLM token consumption across all iterations in a turn.
type TokenUsage struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
Model string `json:"model"`
Provider string `json:"provider,omitempty"`
}
// ResponseMetrics captures execution metrics for a single agent processing turn.
type ResponseMetrics struct {
DurationMs int64 `json:"durationMs"`
TokenUsage *TokenUsage `json:"tokenUsage,omitempty"`
ToolCalls int `json:"toolCalls"`
Iterations int `json:"iterations"`
Model string `json:"model,omitempty"`
} }
// MediaPart describes a single media attachment to send. // MediaPart describes a single media attachment to send.

View file

@ -38,8 +38,31 @@ type WebhookPayload struct {
type CallbackPayload struct { type CallbackPayload struct {
StackID string `json:"stackId"` StackID string `json:"stackId"`
ConversationID string `json:"conversationId"` ConversationID string `json:"conversationId"`
TaskID string `json:"taskId,omitempty"`
Type string `json:"type"` // "final", "progress", "escalation"
Status string `json:"status"` // "success", "error"
Response string `json:"response"` Response string `json:"response"`
Type string `json:"type"` // "final" Error string `json:"error,omitempty"`
Runtime string `json:"runtime"`
DurationMs int64 `json:"durationMs,omitempty"`
TokenUsage *bus.TokenUsage `json:"tokenUsage,omitempty"`
ToolCalls int `json:"toolCalls,omitempty"`
Progress *ProgressPayload `json:"progress"` // null unless type=progress
Escalation *EscalationPayload `json:"escalation"` // null unless type=escalation
}
// ProgressPayload is populated for type="progress" callbacks.
type ProgressPayload struct {
Status string `json:"status"` // e.g. "thinking"
ToolName string `json:"toolName,omitempty"`
StepNumber int `json:"stepNumber,omitempty"`
Message string `json:"message,omitempty"`
}
// EscalationPayload is populated for type="escalation" callbacks.
type EscalationPayload struct {
Reason string `json:"reason"`
Notes string `json:"notes,omitempty"`
} }
// requestContext stores per-request state so Send() can resolve callback info. // requestContext stores per-request state so Send() can resolve callback info.
@ -332,12 +355,23 @@ func (c *MagicFormChannel) Send(ctx context.Context, msg bus.OutboundMessage) er
return channels.ErrNotRunning return channels.ErrNotRunning
} }
// Look up request context // For progress/escalation messages, Load (keep) the context since the final
// message is still coming. For final messages, LoadAndDelete to clean up.
isFinal := msg.Type == bus.MessageTypeFinal
var reqCtx *requestContext
if isFinal {
val, ok := c.requests.LoadAndDelete(msg.ChatID) val, ok := c.requests.LoadAndDelete(msg.ChatID)
if !ok { if !ok {
return fmt.Errorf("%w: no request context for chatID %s", channels.ErrSendFailed, msg.ChatID) return fmt.Errorf("%w: no request context for chatID %s", channels.ErrSendFailed, msg.ChatID)
} }
reqCtx := val.(*requestContext) reqCtx = val.(*requestContext)
} else {
val, ok := c.requests.Load(msg.ChatID)
if !ok {
return fmt.Errorf("%w: no request context for chatID %s", channels.ErrSendFailed, msg.ChatID)
}
reqCtx = val.(*requestContext)
}
// Resolve callback URL // Resolve callback URL
callbackURL := reqCtx.callbackURL callbackURL := reqCtx.callbackURL
@ -350,11 +384,44 @@ func (c *MagicFormChannel) Send(ctx context.Context, msg bus.OutboundMessage) er
} }
// Build callback payload // Build callback payload
taskID := fmt.Sprintf("claw_task_%s_%d", reqCtx.conversationID, reqCtx.createdAt.UnixMilli())
payload := CallbackPayload{ payload := CallbackPayload{
StackID: reqCtx.stackID, StackID: reqCtx.stackID,
ConversationID: reqCtx.conversationID, ConversationID: reqCtx.conversationID,
TaskID: taskID,
Runtime: "picoclaw",
Response: msg.Content, Response: msg.Content,
Type: "final", }
switch msg.Type {
case bus.MessageTypeProgress:
payload.Type = "progress"
payload.Status = "success"
if msg.Progress != nil {
payload.Progress = &ProgressPayload{
Status: msg.Progress.Status,
ToolName: msg.Progress.ToolName,
StepNumber: msg.Progress.StepNumber,
Message: msg.Progress.Message,
}
}
case bus.MessageTypeEscalation:
payload.Type = "escalation"
payload.Status = "success"
if msg.Escalation != nil {
payload.Escalation = &EscalationPayload{
Reason: msg.Escalation.Reason,
Notes: msg.Escalation.Notes,
}
}
default: // final
payload.Type = "final"
payload.Status = "success"
if m := msg.Metrics; m != nil {
payload.DurationMs = m.DurationMs
payload.ToolCalls = m.ToolCalls
payload.TokenUsage = m.TokenUsage
}
} }
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
@ -385,6 +452,7 @@ func (c *MagicFormChannel) Send(ctx context.Context, msg bus.OutboundMessage) er
logger.InfoCF("magicform", "Callback sent", logger.InfoCF("magicform", "Callback sent",
map[string]any{ map[string]any{
"conversation_id": reqCtx.conversationID, "conversation_id": reqCtx.conversationID,
"type": payload.Type,
"status": resp.StatusCode, "status": resp.StatusCode,
}) })