Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 49 additions & 56 deletions docs/jiuwenclaw.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,36 @@
# jiuwenclaw Adapter

SkVM's `jiuwenclaw` adapter wraps [jiuwenclaw](https://github.com/openJiuwen-ai/jiuwenclaw) by launching `python -m jiuwenclaw.app` as a sidecar and driving it over ACP (Agent Client Protocol) JSON-RPC on `127.0.0.1:19001`.
SkVM's `jiuwenclaw` adapter wraps [jiuwenclaw](https://github.com/openJiuwen-ai/jiuwenclaw) by launching `python -m jiuwenswarm.app` as a sidecar and driving it over ACP (Agent Client Protocol) JSON-RPC on `127.0.0.1:19001`.

> **Required upstream patch** — jiuwenclaw as of commit `2ca9ce3` has a bug on the ACP envelope path that drops all streaming events and hangs the session. Apply the patch in [Required patch](#required-patch) before running.
> **Upstream rename note.** The upstream project renamed the Python package from `jiuwenclaw` to `jiuwenswarm` (v0.2.0, May 2026). The GitHub repo URL is unchanged. SkVM keeps the adapter name as `jiuwenclaw` so existing CLI flags (`--harness=jiuwenclaw`) and `~/.skvm/proposals/jit-optimize/jiuwenclaw/…` paths remain stable.

## Prerequisites

- Python **3.11+** (jiuwenclaw's `pyproject.toml` pins `>=3.11,<3.14`).
- A jiuwenclaw source checkout — the adapter runs it from source, not from a pip install.
- `OPENROUTER_API_KEY` in your environment. The adapter writes a deterministic `.env` at setup time that routes jiuwenclaw's LLM calls through OpenRouter with this key.
- Python **3.11+** (jiuwenswarm's `pyproject.toml` pins `>=3.11,<3.14`).
- A jiuwenswarm source checkout — the adapter runs it from source, not from a pip install.
- `OPENROUTER_API_KEY` (or whichever provider env var matches your `--model=`'s `providers.routes` entry) in your environment. The adapter writes a deterministic `.env` at sidecar boot time that pins the resolved API base / key / model name on the AgentServer side.
- A jiuwenswarm build that supports `params.workspace_dir` on `session/prompt` and emits `chat.usage_metadata` stream events. Sanity-check with:
```bash
python -m jiuwenswarm.channels.acp.app_acp acp --help | grep workspace-dir
```

## Install jiuwenclaw
## Install jiuwenswarm

Clone jiuwenclaw anywhere on disk and create a Python 3.11+ virtual environment. The examples below use `$JIUWENCLAW_DIR` as a stand-in for whichever directory you pick.
Clone jiuwenswarm anywhere on disk and create a Python 3.11+ virtual environment. The examples below use `$JIUWENSWARM_DIR` as a stand-in for whichever directory you pick.

```bash
export JIUWENCLAW_DIR=/path/to/jiuwenclaw # pick any directory
git clone https://github.com/openJiuwen-ai/jiuwenclaw.git "$JIUWENCLAW_DIR"
cd "$JIUWENCLAW_DIR"
export JIUWENSWARM_DIR=/path/to/jiuwenswarm # pick any directory
git clone https://github.com/openJiuwen-ai/jiuwenclaw.git "$JIUWENSWARM_DIR"
cd "$JIUWENSWARM_DIR"
uv venv --python 3.12
uv sync
```

Verify the install resolves imports:
Verify the install resolves imports and exposes the workspace flag:

```bash
"$JIUWENCLAW_DIR/.venv/bin/python" -c "import jiuwenclaw.app_cli; import jiuwenclaw.app"
"$JIUWENSWARM_DIR/.venv/bin/python" -c "import jiuwenswarm.app; import jiuwenswarm.channels.acp.app_acp"
"$JIUWENSWARM_DIR/.venv/bin/python" -m jiuwenswarm.channels.acp.app_acp acp --help | grep workspace-dir
```

## Configure SkVM
Expand All @@ -35,84 +40,72 @@ Point `skvm.config.json` at your checkout (absolute or `~/`-prefixed paths both
```json
{
"adapters": {
"jiuwenclaw": "/path/to/jiuwenclaw"
"jiuwenclaw": "/path/to/jiuwenswarm"
}
}
```

With `adapters.jiuwenclaw` set, `src/adapters/jiuwenclaw.ts` resolves the CLI as `python3 -m jiuwenclaw.app_cli` (and spawns the sidecar the same way). It does **not** look up `jiuwenclaw-cli` on `PATH`.
With `adapters.jiuwenclaw` set, `src/adapters/jiuwenclaw.ts` resolves the ACP stdio bridge as `python3 -m jiuwenswarm.channels.acp.app_acp` and spawns the orchestrator as `python3 -m jiuwenswarm.app`. It does **not** look up `jiuwenswarm-tui` on `PATH`.

The adapter hardcodes `python3` (no venv-aware resolution yet), so **activate the venv before invoking skvm**:

```bash
source "$JIUWENCLAW_DIR/.venv/bin/activate"
which python3 # → $JIUWENCLAW_DIR/.venv/bin/python3
source "$JIUWENSWARM_DIR/.venv/bin/activate"
which python3 # → $JIUWENSWARM_DIR/.venv/bin/python3

bun run skvm run \
--task=skvm-data/tasks/file-operations_task_01/task.json \
--adapter=jiuwenclaw \
--model=openrouter/z-ai/glm-5.1 \
--verbose
--adapter-config=managed \
--model=deepseek/deepseek-chat
```

`--adapter-config=managed` is required (or `defaults.adapterConfigMode=managed` in `skvm.config.json`); jiuwenswarm rejects native mode because its `set_user_home()` Python API only scopes config for the in-process side, not for the spawned `app_agentserver` + `app_gateway` children.

## How setup/teardown works

On each run the adapter acquires a cross-process file lock at `~/.jiuwenclaw/jiuwenclaw.sidecar.lock` — port 19001 and `~/.jiuwenclaw/config/.env` are both user-global singletons, so at most one sidecar may live at a time across all skvm processes on the host.
On each adapter `setup()` the SkVM driver acquires a cross-process file lock at `~/.jiuwenswarm/jiuwenclaw.sidecar.lock` — port 19001 and `~/.jiuwenswarm/config/.env` are both user-global singletons, so at most one sidecar may live at a time across all skvm processes on the host.

It then:

1. Backs up any existing `~/.jiuwenclaw/config/.env` to `.env.skvm-backup`.
1. Backs up any existing `~/.jiuwenswarm/config/.env` to `.env.skvm-backup`.
2. Overwrites `.env` with a deterministic minimal file (`API_BASE`, `API_KEY`, `MODEL_NAME`, `MODEL_PROVIDER`, `BROWSER_RUNTIME_MCP_ENABLED=0`) — this is why **bench results are reproducible across machines** regardless of what local tool credentials (`SERPER_API_KEY`, `VISION_*`, etc.) you have configured.
3. Spawns `python3 -m jiuwenclaw.app` and waits up to 60s for the gateway port to accept connections.
3. Spawns `python3 -m jiuwenswarm.app` and waits up to 60s for the gateway port to accept connections.

On teardown the backup is restored and the sidecar process is killed. If a previous run crashed hard and left a stale `.env.skvm-backup`, the new run treats that backup as the true original — user credentials are never silently lost.

## Known limitations
`setup()` and `teardown()` are reference-counted on the adapter side: the bench / jit-optimize stack calls both at the orchestrator level *and* inside `runTask`, and reentrant invocations no-op while the outermost setup is still active. This is invisible to non-jiuwenclaw adapters whose setup is cheap to repeat; for jiuwenclaw it prevents the inner setup from deadlocking on the host-wide sidecar lock the orchestrator already owns.

### Token/cost not reported
## Per-request workspace

jiuwenclaw's AgentServer does not surface per-request token/cost totals to ACP clients, so bench and profile aggregators will report `$0` and `tokens: in=0 out=0` for every jiuwenclaw run. This is by design in the adapter — don't use jiuwenclaw for cost/throughput benchmarks.
Each `run()` passes the SkVM-allocated `task.workDir` as `--workspace-dir` to `app_acp`. The patched AgentServer threads the path into both `inputs["cwd"]` and `inputs["workspace_dir"]`, which the per-request `_update_runtime_config` → `_seed_runtime_cwd` → `init_cwd(cwd=…, workspace=…)` chain installs onto openjiuwen's `CwdState` ContextVar — covering both `get_cwd()` (relative-path resolution) and `get_workspace()` (the `fs_operation` sandbox membership check that gates absolute-path writes). Bench's `file-check` evaluators then read from `task.workDir` after the run.

### Files are written to jiuwenclaw's workspace, not SkVM's workDir
The driver also prepends a one-line working-directory hint to the prompt (`Your working directory is X. Use relative paths …`).

Every filesystem tool call from jiuwenclaw's agent resolves paths against its own workspace (`~/.jiuwenclaw/agent/jiuwenclaw_workspace/`), not the `workDir` skvm allocates per task. Any eval that reads the task's `workDir` (e.g. `file-check`) will find nothing and fail.
## Token, cost, and error reporting

Until this is fixed (either in jiuwenclaw by honoring a session-level cwd, or in the adapter by plumbing `workDir` through and rewriting paths), jiuwenclaw runs are best used for **execution smoke tests** with `skvm run`, not for `skvm bench` with automated grading.
Per-LLM-call usage flows through `chat.usage_metadata` events written into `~/.jiuwenswarm/agent/sessions/<id>/history.json`. The adapter sums them into `RunResult.tokens` (`input` / `output`) and accumulates `total_cost` per call into `RunResult.cost`. Cost is only populated when the underlying provider client surfaces it via `_extract_cost_info` (currently OpenAI / OpenRouter routes). DeepSeek and other plain `openai-compatible` routes report tokens correctly but cost as `$0`.

## Required patch
`chat.error` events carry an `error_type` field (the originating Python exception class). `diagnoseJiuwenclaw` prefixes the failure summary with `[ErrorType] …` so the SkVM bench post-mortem groups failures structurally.

**Upstream**: `jiuwenclaw/channel/acp_channel.py`, commit [`2ca9ce3`](https://github.com/openJiuwen-ai/jiuwenclaw/commit/2ca9ce3e0eaa60d46b4026bf25000b172c5bff8e).
## Known limitations

**Symptom without the patch**: the sidecar boots, the ACP session is created, the LLM is invoked, but SkVM's request hangs indefinitely waiting for a final frame. Verbose logs show streaming chunks being generated on the server side but never arriving at the client.
### History.json is keyed by an internal session id

**Root cause**: the JSON-RPC `session.create`/`prompt` path in `AcpChannel` populates `_active_prompt_request_by_session[session_id] = msg.id`, which is what `_message_from_gateway_event` consults to route gateway event chunks back to an originating request. The **envelope path** (`_handle_raw_line` when the peer is using E2AEnvelope instead of raw JSON-RPC — this includes `jiuwenclaw.app_cli` itself, which SkVM drives) only populates `_request_ctx[msg.id]` and skips the session→request mapping, so streaming events are silently dropped and the final frame is never emitted.
jiuwenswarm's AgentServer remaps the client-supplied session_id to an internal `acp_*` id before writing `history.json`. The adapter snapshots the `~/.jiuwenswarm/agent/sessions/` directory before each run and picks the freshly-created entry as the path to read; this is robust but synthetic. Tracking upstream change to surface the internal id directly on `chat.final`.

**Fix**: mirror the same registration on the envelope path. Insert the block below in `_handle_raw_line`, immediately after the `_request_ctx[msg.id] = _AcpRequestContext(...)` assignment and before `await self._dispatch_message(msg)`:
### System prompt still references the static workspace

```python
# jiuwenclaw/channel/acp_channel.py — _handle_raw_line, ~line 178
self._request_ctx[msg.id] = _AcpRequestContext(
jsonrpc_id=env.jsonrpc_id,
method=env.method,
response_mode="e2a",
session_id=msg.session_id,
)
# Mirror the JSON-RPC session/prompt path: register the session→request
# mapping so gateway event chunks (_message_from_gateway_event) can
# resolve their request_id and emit responses. Without this, streaming
# events on the envelope path are silently dropped and the channel
# hangs waiting for a final frame that never arrives.
if msg.session_id:
self._active_prompt_request_by_session[msg.session_id] = msg.id
jiuwenswarm's *system prompt* (built by `prompt_builder.py` at sidecar startup) names the home-dir workspace path. The per-request override covers `get_cwd()` and `get_workspace()` — so a model that emits absolute paths matching that prompt will write under the global workspace anyway, where `fs_operation`'s sandbox-membership check now rejects them (the per-request workspace doesn't contain that path). Models that follow the hint and use relative paths land in `task.workDir`. A future upstream PR threading `workspace_dir` into `runtime_prompt_rail`'s system-prompt template would close this gap.

await self._dispatch_message(msg)
```
### Subagents inherit the static workspace

Until this lands upstream, apply it in your local checkout:
`Workspace(root_path=…)` is built once at sidecar startup and passed into code / research subagents. The per-request `CwdState` override only re-seeds the parent agent's task context, so subagent path resolution that goes through `Workspace.root_path` (rather than `get_cwd()` / `get_workspace()`) still resolves under the home-dir workspace. Tasks that don't trigger subagents are unaffected; benchmarks that do should expect mixed file landing.

```bash
cd "$JIUWENCLAW_DIR"
git fetch origin
git cherry-pick 2ca9ce3 # if it's on a branch you haven't merged
# or pull from whichever branch carries the fix
```
### Non-streaming `process_message_impl` doesn't carry `error_type`

The streaming aggregator in `interface.py:process_message_stream` and the streaming exception handler in `interface_deep.py` both attach `error_type` on `chat.error`. Non-streaming `process_message_impl` returns an `AgentResponse` without an analogous error classification; SkVM uses streaming exclusively so this has no impact today.

### macOS teardown can leave orphans

`jiuwenswarm.app/main()` only runs its `_terminate_all()` finally block on `KeyboardInterrupt`, not on `SIGTERM` — so killing the orchestrator pid leaves `app_agentserver` and `app_gateway` as orphans. The adapter mitigates with a post-teardown `pkill -f 'jiuwenswarm\.app'` sweep and waits up to 5s for port 19001 to clear.
8 changes: 5 additions & 3 deletions src/adapters/diagnose-failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,9 +304,11 @@ export async function diagnoseJiuwenclaw(input: DiagnoseInput): Promise<FailureD
for (let i = records.length - 1; i >= 0; i--) {
const rec = records[i] as Record<string, unknown> | undefined
if (rec && rec.event_type === "chat.error") {
const content = rec.content
if (typeof content === "string") {
return { summary: `jiuwenclaw: ${content}`, source: "jiuwenclaw:history" }
const content = typeof rec.content === "string" ? rec.content : ""
const errorType = typeof rec.error_type === "string" ? rec.error_type : ""
if (content) {
const prefix = errorType ? `[${errorType}] ` : ""
return { summary: `jiuwenclaw: ${prefix}${content}`, source: "jiuwenclaw:history" }
}
}
}
Expand Down
59 changes: 53 additions & 6 deletions src/adapters/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,32 @@ export function buildMinimalResult(
}
}

/** Drain a byte stream to text, with the ability to abandon a read that is
* blocked on a pipe whose EOF never arrives (e.g. a surviving grandchild
* still holds the FD). `abandon()` cancels the reader so the in-flight
* `read()` resolves instead of hanging forever. */
function drainStreamText(stream: ReadableStream<Uint8Array>): {
text: Promise<string>
abandon: () => void
} {
const reader = stream.getReader()
const decoder = new TextDecoder()
let out = ""
const text = (async () => {
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (value) out += decoder.decode(value, { stream: true })
}
} catch {
// reader was cancelled (abandon) or the stream errored — return partial
}
return out
})()
return { text, abandon: () => { reader.cancel().catch(() => {}) } }
}

export async function runCommandWithEnv(
cmd: string[],
opts?: { cwd?: string; timeout?: number; env?: Record<string, string | undefined> },
Expand All @@ -707,21 +733,42 @@ export async function runCommandWithEnv(
stdout: "pipe",
stderr: "pipe",
env: opts?.env ?? process.env,
// Own process group, so a timeout can reap the WHOLE tree. The spawned
// CLI may fork grandchildren that inherit our stdout/stderr pipe FDs;
// killing only the direct child orphans them holding the pipes open, and
// the EOF reads below would then hang forever (observed: an 8.75h hang
// where a jiuwenswarm ACP child outlived a SIGTERM'd parent).
detached: true,
})

let timedOut = false
let timer: ReturnType<typeof setTimeout> | undefined
if (opts?.timeout) {
timer = setTimeout(() => {
timedOut = true
proc.kill()
// Negative pid = kill the whole process group (child + grandchildren).
// SIGKILL so a parent that ignores SIGTERM (or dies before cleaning up
// its children) can't leave the tree alive holding our pipes.
try {
process.kill(-proc.pid, "SIGKILL")
} catch {
try { proc.kill() } catch { /* already gone */ }
}
}, opts.timeout)
}

const [exitCode, stdout, stderr] = await Promise.all([
proc.exited.then((code) => { if (timer) clearTimeout(timer); return code }),
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
])
// Read both pipes, but never block past process exit. Group-kill above
// should close the pipes, but as a backstop: once the process is gone, give
// the pipes a short grace to drain, then abandon the readers so a stray FD
// holder can't deadlock us on EOF.
const so = drainStreamText(proc.stdout)
const se = drainStreamText(proc.stderr)
const exitCode = await proc.exited
if (timer) clearTimeout(timer)

const drainGrace = setTimeout(() => { so.abandon(); se.abandon() }, 2000)
const [stdout, stderr] = await Promise.all([so.text, se.text])
clearTimeout(drainGrace)

return { stdout, stderr, exitCode, timedOut }
}
Loading