feat/plugin-services-restructure #1
236
docs/TEST_FLOW.md
Normal file
236
docs/TEST_FLOW.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# ContractorAgent E2E Test Flow
|
||||
|
||||
End-to-end test scenarios for the contractor-agent plugin. Each scenario describes
|
||||
the precondition, the action, and the expected observable outcome.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### ST-1 — Clean install
|
||||
|
||||
**Precondition**: Plugin not installed; test agent workspaces do not exist.
|
||||
|
||||
**Steps**:
|
||||
1. `node scripts/install.mjs --uninstall` (idempotent if already gone)
|
||||
2. Remove test agents from `openclaw.json` if present
|
||||
3. `node scripts/install.mjs --install`
|
||||
4. `openclaw gateway restart`
|
||||
|
||||
**Expected**:
|
||||
- `GET /health` → `{"ok":true,"service":"contractor-bridge"}`
|
||||
- `GET /v1/models` → list includes `contractor-claude-bridge` and `contractor-gemini-bridge`
|
||||
- OpenClaw logs: `[contractor-agent] plugin registered (bridge port: 18800)`
|
||||
|
||||
---
|
||||
|
||||
### ST-2 — Provision Claude contractor agent
|
||||
|
||||
**Command**: `openclaw contractor-agents add --agent-id claude-e2e --workspace /tmp/claude-e2e-workspace --contractor claude`
|
||||
|
||||
**Expected**:
|
||||
- `openclaw.json` agents list contains `claude-e2e` with model `contractor-agent/contractor-claude-bridge`
|
||||
- `/tmp/claude-e2e-workspace/.openclaw/contractor-agent/session-map.json` exists (empty sessions)
|
||||
- Workspace files created by `openclaw agents add` are present (SOUL.md, IDENTITY.md, etc.)
|
||||
|
||||
---
|
||||
|
||||
### ST-3 — Provision Gemini contractor agent
|
||||
|
||||
**Command**: `openclaw contractor-agents add --agent-id gemini-e2e --workspace /tmp/gemini-e2e-workspace --contractor gemini`
|
||||
|
||||
**Expected**: same as ST-2 but model is `contractor-agent/contractor-gemini-bridge`.
|
||||
|
||||
---
|
||||
|
||||
## Core Bridge Behaviour
|
||||
|
||||
### CB-1 — First turn bootstraps persona (Claude)
|
||||
|
||||
**Precondition**: `claude-e2e` has no active session (session-map empty).
|
||||
|
||||
**Request**: POST `/v1/chat/completions`, model `contractor-agent/contractor-claude-bridge`,
|
||||
system message contains `Runtime: agent=claude-e2e | repo=/tmp/claude-e2e-workspace`,
|
||||
user message: `"Introduce yourself briefly."`
|
||||
|
||||
**Expected**:
|
||||
- Response streams SSE chunks, terminates with `[DONE]`
|
||||
- Response reflects the persona from `SOUL.md`/`IDENTITY.md` — agent uses the name
|
||||
and tone defined in the workspace files (no generic "I'm Claude …" dry response)
|
||||
- `session-map.json` now contains one entry with `contractor=claude`, `state=active`,
|
||||
and a non-empty `claudeSessionId`
|
||||
|
||||
**Mechanism**: bootstrap is injected only on the first turn; it embeds the content of
|
||||
SOUL.md, IDENTITY.md, USER.md, MEMORY.md inline so the agent adopts the persona
|
||||
immediately without needing to read files itself.
|
||||
|
||||
---
|
||||
|
||||
### CB-2 — Session resume retains context (Claude)
|
||||
|
||||
**Precondition**: CB-1 has run; session-map holds an active session.
|
||||
|
||||
**Request**: same headers, user message: `"What did I ask you in my first message?"`
|
||||
|
||||
**Expected**:
|
||||
- Agent recalls the previous question without re-reading files
|
||||
- `session-map.json` `lastActivityAt` updated; `claudeSessionId` unchanged
|
||||
|
||||
**Mechanism**: bridge detects existing active session, passes `--resume <sessionId>` to
|
||||
Claude Code; bootstrap is NOT re-injected.
|
||||
|
||||
---
|
||||
|
||||
### CB-3 — MCP tool relay — contractor_echo (Claude)
|
||||
|
||||
**Precondition**: active session from CB-1; request includes `contractor_echo` tool definition.
|
||||
|
||||
**Request**: user message: `"Use the contractor_echo tool to echo: hello"`
|
||||
|
||||
**Expected**:
|
||||
- Agent calls the `mcp__openclaw__contractor_echo` tool via the MCP proxy
|
||||
- Bridge relays the call to `POST /mcp/execute` → OpenClaw plugin registry
|
||||
- Response confirms the echo with a timestamp: `"Echo confirmed: hello at …"`
|
||||
|
||||
**Mechanism**: bridge writes an MCP config file pointing to
|
||||
`services/openclaw-mcp-server.mjs` before each `claude` invocation; the MCP server
|
||||
forwards tool calls to the bridge `/mcp/execute` endpoint which resolves them through
|
||||
the OpenClaw global plugin registry.
|
||||
|
||||
---
|
||||
|
||||
### CB-4 — First turn bootstraps persona (Gemini)
|
||||
|
||||
Same as CB-1 but model `contractor-agent/contractor-gemini-bridge`, agent `gemini-e2e`.
|
||||
|
||||
**Expected**: persona from `SOUL.md`/`IDENTITY.md` is reflected; session entry has
|
||||
`contractor=gemini`.
|
||||
|
||||
**Mechanism**: bootstrap is identical; Gemini CLI receives the full prompt via `-p`.
|
||||
MCP config is written to `workspace/.gemini/settings.json` (Gemini's project settings
|
||||
path) instead of an `--mcp-config` flag.
|
||||
|
||||
---
|
||||
|
||||
### CB-5 — Session resume (Gemini)
|
||||
|
||||
Same as CB-2 but for Gemini.
|
||||
|
||||
**Expected**: agent recalls prior context via `--resume <UUID>`.
|
||||
|
||||
---
|
||||
|
||||
### CB-6 — MCP tool relay (Gemini)
|
||||
|
||||
Same as CB-3 but for Gemini. Gemini sees the tool as `mcp_openclaw_contractor_echo`
|
||||
(single-underscore FQN; server alias is `openclaw` with no underscores).
|
||||
|
||||
**Expected**: echo confirmed in response.
|
||||
|
||||
---
|
||||
|
||||
## Skill Invocation
|
||||
|
||||
### SK-1 — Agent reads and executes a skill script (Claude)
|
||||
|
||||
**Precondition**: fresh session (session-map cleared); skill
|
||||
`contractor-test-skill` is installed in `~/.openclaw/skills/contractor-test-skill/`.
|
||||
System message includes:
|
||||
|
||||
```xml
|
||||
<available_skills>
|
||||
<skill>
|
||||
<name>contractor-test-skill</name>
|
||||
<description>Test skill for verifying that a contractor agent can discover and invoke a workspace script…</description>
|
||||
<location>/home/hzhang/.openclaw/skills/contractor-test-skill</location>
|
||||
</skill>
|
||||
</available_skills>
|
||||
```
|
||||
|
||||
**Request**: user message: `"Run the contractor test skill and show me the output."`
|
||||
|
||||
**Expected**:
|
||||
- Agent reads `SKILL.md` at the given `<location>`
|
||||
- Expands `{baseDir}` → `/home/hzhang/.openclaw/skills/contractor-test-skill`
|
||||
- Executes `scripts/test.sh` via Bash
|
||||
- Response contains:
|
||||
```
|
||||
=== contractor-test-skill: PASSED ===
|
||||
Timestamp: <ISO-8601>
|
||||
```
|
||||
|
||||
**Why first turn matters**: the bootstrap embeds the `<available_skills>` block in the
|
||||
system prompt sent to Claude on the first turn. Claude retains this in session memory
|
||||
for subsequent turns. If the session is already active when the skill-bearing request
|
||||
arrives, Claude won't know about the skill.
|
||||
|
||||
---
|
||||
|
||||
### SK-2 — Agent reads and executes a skill script (Gemini)
|
||||
|
||||
Same as SK-1 but for Gemini. Gemini reads SKILL.md and executes the script.
|
||||
|
||||
**Expected**: same `PASSED` output block.
|
||||
|
||||
---
|
||||
|
||||
## Skills Injection Timing
|
||||
|
||||
### Background — How OpenClaw injects skills
|
||||
|
||||
OpenClaw rebuilds the system prompt (including `<available_skills>`) on **every turn**
|
||||
and sends it to the model. The skill list comes from a **snapshot** cached in the
|
||||
session entry, refreshed under the following conditions:
|
||||
|
||||
| Trigger | Refresh? |
|
||||
|---------|----------|
|
||||
| First turn in a new session | ✅ Always |
|
||||
| Skills directory file changed (file watcher detects version bump) | ✅ Yes |
|
||||
| `openclaw gateway restart` (session entry survives) | ❌ No — old snapshot reused |
|
||||
| `openclaw gateway restart` + session reset / new session | ✅ Yes — first-turn logic runs |
|
||||
| Skill filter changes | ✅ Yes |
|
||||
|
||||
### Implication for the contractor bridge
|
||||
|
||||
The bridge receives the skills block on **every** incoming turn but currently only uses
|
||||
it in the **first-turn bootstrap**. Subsequent turns carry updated skills if OpenClaw
|
||||
refreshed the snapshot, but the bridge does not re-inject them into the running
|
||||
Claude/Gemini session.
|
||||
|
||||
**Consequence**: if skills are added or removed while a contractor session is active,
|
||||
the agent won't see the change until the session is reset (session-map cleared) and a
|
||||
new bootstrap is sent.
|
||||
|
||||
This is intentional for v1: contractor sessions are meant to be long-lived, and skill
|
||||
changes mid-session are uncommon. If needed, explicitly clearing the session map forces
|
||||
a new bootstrap on the next turn.
|
||||
|
||||
---
|
||||
|
||||
## Error Cases
|
||||
|
||||
### ER-1 — No active session, no workspace in system message
|
||||
|
||||
**Request**: system message has no `Runtime:` line.
|
||||
|
||||
**Expected**: bridge logs a warning, falls back to `/tmp` workspace, session key is empty,
|
||||
response may succeed but session is not persisted.
|
||||
|
||||
---
|
||||
|
||||
### ER-2 — Gemini CLI not installed
|
||||
|
||||
**Request**: model `contractor-agent/contractor-gemini-bridge`.
|
||||
|
||||
**Expected**: `dispatchToGemini` spawn fails, bridge streams
|
||||
`[contractor-bridge dispatch failed: …]` error chunk, then `[DONE]`.
|
||||
Session is not persisted; if a prior session existed, it is marked `orphaned`.
|
||||
|
||||
---
|
||||
|
||||
### ER-3 — MCP tool not registered in plugin registry
|
||||
|
||||
**Request**: tool `unknown_tool` called via `/mcp/execute`.
|
||||
|
||||
**Expected**: `POST /mcp/execute` returns `{"error":"Tool 'unknown_tool' not registered…"}` (200).
|
||||
The agent receives the error text as the tool result and surfaces it in its reply.
|
||||
258
docs/claude/BRIDGE_MODEL_FINDINGS.md
Normal file
258
docs/claude/BRIDGE_MODEL_FINDINGS.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# Bridge Model Probe Findings
|
||||
|
||||
## Purpose
|
||||
|
||||
Document actual test results from running the `contractor-probe` test plugin against a live
|
||||
OpenClaw gateway. Resolves the two critical unknowns identified in earlier feasibility review.
|
||||
|
||||
Test setup: installed `contractor-probe` plugin that exposes an OpenAI-compatible HTTP server
|
||||
on port 8799 and logs every raw request body to `/tmp/contractor-probe-requests.jsonl`.
|
||||
Created a `probe-test` agent with `model: contractor-probe/contractor-probe-bridge`.
|
||||
Sent two consecutive messages via `openclaw agent --channel qa-channel`.
|
||||
|
||||
---
|
||||
|
||||
## Finding 1 — Custom Model Registration Mechanism
|
||||
|
||||
**There is no plugin SDK `registerModelProvider` call.**
|
||||
|
||||
The actual mechanism used by dirigent (and confirmed working for contractor-probe) is:
|
||||
|
||||
### Step 1 — Add provider to `openclaw.json`
|
||||
|
||||
Under `models.providers`, add an entry pointing to a local OpenAI-compatible HTTP server:
|
||||
|
||||
```json
|
||||
"contractor-probe": {
|
||||
"baseUrl": "http://127.0.0.1:8799/v1",
|
||||
"apiKey": "probe-local",
|
||||
"api": "openai-completions",
|
||||
"models": [{
|
||||
"id": "contractor-probe-bridge",
|
||||
"name": "Contractor Probe Bridge (test)",
|
||||
"reasoning": false,
|
||||
"input": ["text"],
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 4096
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2 — Plugin starts a sidecar HTTP server
|
||||
|
||||
The plugin's `register()` function starts a Node.js HTTP server on `gateway_start` (protected
|
||||
by a `globalThis` flag to prevent double-start on hot-reload). The server implements:
|
||||
|
||||
- `GET /v1/models` — model list
|
||||
- `POST /v1/chat/completions` — model inference (must support streaming)
|
||||
- `POST /v1/responses` — responses API variant (optional)
|
||||
|
||||
### Step 3 — Agent uses `provider/model` as primary model
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-agent",
|
||||
"model": { "primary": "contractor-probe/contractor-probe-bridge" }
|
||||
}
|
||||
```
|
||||
|
||||
### What this means for ContractorAgent
|
||||
|
||||
The `contractor-claude-bridge` model should be registered the same way:
|
||||
|
||||
1. Install script writes a provider entry to `openclaw.json` pointing to the bridge sidecar port
|
||||
2. Plugin starts the bridge sidecar on `gateway_start`
|
||||
3. `openclaw contractor-agents add` sets the agent's primary model to `contractor-claude-bridge`
|
||||
|
||||
No plugin SDK model registration API exists or is needed.
|
||||
|
||||
---
|
||||
|
||||
## Finding 2 — Exact Payload Sent to Custom Model
|
||||
|
||||
OpenClaw sends a standard OpenAI Chat Completions request to the sidecar on every turn.
|
||||
|
||||
### Endpoint and transport
|
||||
|
||||
```
|
||||
POST /v1/chat/completions
|
||||
Content-Type: application/json
|
||||
stream: true ← streaming is always requested; sidecar MUST emit SSE
|
||||
```
|
||||
|
||||
### Message array structure
|
||||
|
||||
**Turn 1 (2 messages):**
|
||||
|
||||
| index | role | content |
|
||||
|-------|------|---------|
|
||||
| 0 | `system` | Full OpenClaw agent context (~28,000 chars) — rebuilt every turn |
|
||||
| 1 | `user` | `[Sat 2026-04-11 08:32 GMT+1] hello from probe test` |
|
||||
|
||||
**Turn 2 (3 messages):**
|
||||
|
||||
| index | role | content |
|
||||
|-------|------|---------|
|
||||
| 0 | `system` | Same full context (~28,000 chars) |
|
||||
| 1 | `user` | `[Sat 2026-04-11 08:32 GMT+1] hello from probe test` |
|
||||
| 2 | `user` | `[Sat 2026-04-11 08:34 GMT+1] and this is the second message` |
|
||||
|
||||
Note: the probe sidecar did not emit proper SSE. As a result, turn 2 shows no assistant message
|
||||
between the two user messages. Once the bridge sidecar returns well-formed SSE, OpenClaw should
|
||||
include assistant turns in history. Needs follow-up verification with streaming.
|
||||
|
||||
### System prompt contents
|
||||
|
||||
The system prompt is assembled by OpenClaw from:
|
||||
- OpenClaw base instructions (tool call style, scheduling rules, ACP guidance)
|
||||
- Workspace context files (AGENTS.md, SOUL.md, TOOLS.md, IDENTITY.md, USER.md, HEARTBEAT.md, BOOTSTRAP.md)
|
||||
- Skill definitions
|
||||
- Tool guidance text
|
||||
|
||||
In the test, total system prompt was 28,942 chars. This is rebuilt from scratch on every turn.
|
||||
|
||||
### User message format
|
||||
|
||||
```
|
||||
[Day YYYY-MM-DD HH:MM TZ] <message text>
|
||||
```
|
||||
|
||||
Example: `[Sat 2026-04-11 08:32 GMT+1] hello from probe test`
|
||||
|
||||
### Tool definitions
|
||||
|
||||
Full OpenAI function definitions are sent on every request as `tools: [...]`. In the test run,
|
||||
37 tools were included (read, edit, exec, cron, message, sessions_spawn, dirigent tools, etc.).
|
||||
|
||||
### Other request fields
|
||||
|
||||
| field | observed value | notes |
|
||||
|-------|---------------|-------|
|
||||
| `model` | `contractor-probe-bridge` | model id as configured |
|
||||
| `stream` | `true` | always; bridge must stream |
|
||||
| `store` | `false` | |
|
||||
| `max_completion_tokens` | `4096` | from provider model config |
|
||||
|
||||
### What this means for ContractorAgent
|
||||
|
||||
**The input filter is critical.** On every turn, OpenClaw sends:
|
||||
|
||||
- A large system prompt (28K+ chars) that repeats unchanged
|
||||
- The full accumulated user message history
|
||||
- No (or incomplete) assistant message history
|
||||
|
||||
The bridge model must NOT forward this verbatim to Claude Code. Instead:
|
||||
|
||||
1. Extract only the latest user message from the messages array (last `user` role entry)
|
||||
2. Strip the OpenClaw system prompt entirely — Claude Code maintains its own live context
|
||||
3. On first turn: inject a one-time bootstrap block telling Claude it is operating as an
|
||||
OpenClaw contractor agent, with workspace path and session key
|
||||
4. On subsequent turns: forward only the latest user message text
|
||||
|
||||
This keeps Claude as the owner of its own conversational context and avoids dual-context drift.
|
||||
|
||||
**The bridge sidecar must support SSE streaming.** OpenClaw always sets `stream: true`. A
|
||||
non-streaming response causes assistant turn data to be dropped from OpenClaw's session history.
|
||||
|
||||
---
|
||||
|
||||
## Finding 3 — Claude Code Session Continuation Identifier
|
||||
|
||||
From Claude Code documentation research:
|
||||
|
||||
### Session ID format
|
||||
|
||||
UUIDs assigned at session creation. Example: `bc1a7617-0651-443d-a8f1-efeb2957b8c2`
|
||||
|
||||
### Session storage
|
||||
|
||||
```
|
||||
~/.claude/projects/<encoded-cwd>/<session-id>.jsonl
|
||||
```
|
||||
|
||||
`<encoded-cwd>` is the absolute working directory path with every non-alphanumeric character
|
||||
replaced by `-`:
|
||||
- `/home/user/project` → `-home-user-project`
|
||||
|
||||
### CLI resumption
|
||||
|
||||
```bash
|
||||
# Non-interactive mode with session resume
|
||||
claude -p --resume <session-uuid> "next user message"
|
||||
```
|
||||
|
||||
`-p` is print/non-interactive mode. The session UUID is passed as the resume argument.
|
||||
The output includes the session ID so the caller can capture it for next turn.
|
||||
|
||||
### SDK resumption (TypeScript)
|
||||
|
||||
```typescript
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
for await (const message of query({
|
||||
prompt: "next user message",
|
||||
options: {
|
||||
resume: sessionId, // UUID from previous turn
|
||||
allowedTools: ["Read", "Edit", "Glob"],
|
||||
}
|
||||
})) {
|
||||
if (message.type === "result") {
|
||||
const nextSessionId = message.session_id; // capture for next turn
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Session enumeration
|
||||
|
||||
```typescript
|
||||
import { listSessions, getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
const sessions = await listSessions(); // keyed by session UUID
|
||||
const info = await getSessionInfo(sessionId);
|
||||
```
|
||||
|
||||
### What this means for ContractorAgent
|
||||
|
||||
The `SessionMapEntry.claudeSessionId` field should store the UUID returned by `message.session_id`
|
||||
after each Claude turn. On the next turn, pass it as `options.resume`.
|
||||
|
||||
The session file path can be reconstructed from the session ID and workspace path if needed for
|
||||
recovery or inspection, but direct SDK resumption is the primary path.
|
||||
|
||||
---
|
||||
|
||||
## Finding 4 — Sidecar Port Conflict and Globalthis Guard
|
||||
|
||||
During testing, the probe sidecar failed to start with `EADDRINUSE` when `openclaw agent --local`
|
||||
was used alongside a running gateway, because both tried to spawn the server process.
|
||||
|
||||
This is exactly the hot-reload / double-start problem documented in LESSONS_LEARNED items 1, 3,
|
||||
and 7. The fix for the bridge sidecar:
|
||||
|
||||
1. Check a lock file (e.g. `/tmp/contractor-bridge-sidecar.lock`) before starting
|
||||
2. If the lock file exists and the PID is alive, skip start
|
||||
3. Protect the `startSidecar` call with a `globalThis` flag
|
||||
4. Clean up the lock file on `gateway_stop`
|
||||
|
||||
---
|
||||
|
||||
## Open Questions Resolved
|
||||
|
||||
| Question | Status | Answer |
|
||||
|----------|--------|--------|
|
||||
| Custom model registration API? | ✅ Resolved | `openclaw.json` provider config + OpenAI-compatible sidecar |
|
||||
| Claude session continuation identifier? | ✅ Resolved | UUID via `message.session_id`, resume via `options.resume` |
|
||||
| Does OpenClaw include assistant history? | ⚠️ Partial | Appears yes when sidecar streams correctly; needs retest with SSE |
|
||||
| Streaming required? | ✅ Resolved | Yes, `stream: true` always sent; non-streaming drops assistant history |
|
||||
|
||||
---
|
||||
|
||||
## Immediate Next Steps (Updated)
|
||||
|
||||
1. **Add SSE streaming to probe sidecar** — retest to confirm assistant messages appear in turn 3
|
||||
2. **Build the real bridge sidecar** — implement SSE passthrough from Claude SDK output
|
||||
3. **Implement input filter** — extract latest user message, strip system prompt
|
||||
4. **Implement session map store** — persist UUID → OpenClaw session key mapping
|
||||
5. **Implement bootstrap injection** — first-turn only; include workspace path and session key
|
||||
6. **Add lock file to sidecar** — prevent double-start (LESSONS_LEARNED lesson 7)
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "OpenClaw plugin: turns Claude Code into an OpenClaw-managed contractor agent",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"main": "plugin/index.ts",
|
||||
"scripts": {
|
||||
"check": "tsc --noEmit",
|
||||
"install-plugin": "node scripts/install.mjs --install",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import { createBaseAgent } from "../openclaw/agents-add-runner.js";
|
||||
import { markAgentAsClaudeContractor } from "../openclaw/agent-config-writer.js";
|
||||
import { ensureContractorStateDir } from "../contractor/runtime-state.js";
|
||||
import { initEmptySessionMap } from "../contractor/session-map-store.js";
|
||||
import { createBaseAgent } from "../core/openclaw/agents-add-runner.js";
|
||||
import { markAgentAsClaudeContractor, markAgentAsGeminiContractor } from "../core/openclaw/agent-config-writer.js";
|
||||
import { ensureContractorStateDir } from "../core/contractor/runtime-state.js";
|
||||
import { initEmptySessionMap } from "../core/contractor/session-map-store.js";
|
||||
|
||||
export type AddArgs = {
|
||||
agentId: string;
|
||||
@@ -17,24 +17,29 @@ export async function runContractorAgentsAdd(args: AddArgs): Promise<void> {
|
||||
if (!agentId) throw new Error("--agent-id is required");
|
||||
if (!workspace) throw new Error("--workspace is required");
|
||||
if (!contractor) throw new Error("--contractor is required");
|
||||
if (contractor !== "claude") {
|
||||
throw new Error(`--contractor ${contractor}: only 'claude' is supported in phase 1`);
|
||||
if (contractor !== "claude" && contractor !== "gemini") {
|
||||
throw new Error(`--contractor ${contractor}: must be 'claude' or 'gemini'`);
|
||||
}
|
||||
|
||||
const bridgeModel =
|
||||
contractor === "gemini"
|
||||
? "contractor-agent/contractor-gemini-bridge"
|
||||
: "contractor-agent/contractor-claude-bridge";
|
||||
|
||||
// Ensure workspace exists
|
||||
if (!fs.existsSync(workspace)) {
|
||||
fs.mkdirSync(workspace, { recursive: true });
|
||||
}
|
||||
|
||||
console.log(`[contractor-agent] Creating base OpenClaw agent: ${agentId}`);
|
||||
createBaseAgent({
|
||||
agentId,
|
||||
workspace,
|
||||
bridgeModel: "contractor-agent/contractor-claude-bridge",
|
||||
});
|
||||
createBaseAgent({ agentId, workspace, bridgeModel });
|
||||
|
||||
console.log(`[contractor-agent] Writing contractor metadata`);
|
||||
markAgentAsClaudeContractor(agentId, workspace);
|
||||
if (contractor === "gemini") {
|
||||
markAgentAsGeminiContractor(agentId, workspace);
|
||||
} else {
|
||||
markAgentAsClaudeContractor(agentId, workspace);
|
||||
}
|
||||
|
||||
console.log(`[contractor-agent] Initializing runtime state`);
|
||||
ensureContractorStateDir(workspace);
|
||||
@@ -43,6 +48,6 @@ export async function runContractorAgentsAdd(args: AddArgs): Promise<void> {
|
||||
console.log(`[contractor-agent] Done.`);
|
||||
console.log(` Agent: ${agentId}`);
|
||||
console.log(` Workspace: ${workspace}`);
|
||||
console.log(` Model: contractor-agent/contractor-claude-bridge`);
|
||||
console.log(` Model: ${bridgeModel}`);
|
||||
console.log(` State dir: ${workspace}/.openclaw/contractor-agent/`);
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export type OpenAITool = {
|
||||
|
||||
export type ClaudeDispatchOptions = {
|
||||
prompt: string;
|
||||
/** System prompt passed via --system-prompt on every invocation (stateless, not stored in session) */
|
||||
systemPrompt?: string;
|
||||
workspace: string;
|
||||
agentId?: string;
|
||||
resumeSessionId?: string;
|
||||
@@ -30,8 +32,10 @@ export type ClaudeDispatchOptions = {
|
||||
};
|
||||
|
||||
// Resolve the MCP server script path relative to this file.
|
||||
// Installed layout: plugin root / core / claude / sdk-adapter.ts
|
||||
// plugin root / services / openclaw-mcp-server.mjs
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const MCP_SERVER_SCRIPT = path.resolve(__dirname, "../mcp/openclaw-mcp-server.mjs");
|
||||
const MCP_SERVER_SCRIPT = path.resolve(__dirname, "../../services/openclaw-mcp-server.mjs");
|
||||
|
||||
/**
|
||||
* Write OpenClaw tool definitions to a temp file and create an --mcp-config JSON
|
||||
@@ -89,6 +93,7 @@ export async function* dispatchToClaude(
|
||||
): AsyncIterable<ClaudeMessage> {
|
||||
const {
|
||||
prompt,
|
||||
systemPrompt,
|
||||
workspace,
|
||||
agentId = "",
|
||||
resumeSessionId,
|
||||
@@ -110,6 +115,13 @@ export async function* dispatchToClaude(
|
||||
"--dangerously-skip-permissions",
|
||||
];
|
||||
|
||||
// --system-prompt is stateless (not persisted in session file) and fully
|
||||
// replaces any prior system prompt on each invocation, including resumes.
|
||||
// We pass it every turn so skills/persona stay current.
|
||||
if (systemPrompt) {
|
||||
args.push("--system-prompt", systemPrompt);
|
||||
}
|
||||
|
||||
if (resumeSessionId) {
|
||||
args.push("--resume", resumeSessionId);
|
||||
}
|
||||
@@ -8,7 +8,8 @@ function readFile(workspace: string): SessionMapFile {
|
||||
return { version: 1, sessions: [] };
|
||||
}
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(p, "utf8")) as SessionMapFile;
|
||||
const parsed = JSON.parse(fs.readFileSync(p, "utf8")) as Partial<SessionMapFile>;
|
||||
return { version: parsed.version ?? 1, sessions: parsed.sessions ?? [] };
|
||||
} catch {
|
||||
return { version: 1, sessions: [] };
|
||||
}
|
||||
243
plugin/core/gemini/sdk-adapter.ts
Normal file
243
plugin/core/gemini/sdk-adapter.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ClaudeMessage, OpenAITool } from "../claude/sdk-adapter.js";
|
||||
|
||||
// Resolve the MCP server script path relative to this file.
|
||||
// Installed layout: plugin root / core / gemini / sdk-adapter.ts
|
||||
// plugin root / services / openclaw-mcp-server.mjs
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const MCP_SERVER_SCRIPT = path.resolve(__dirname, "../../services/openclaw-mcp-server.mjs");
|
||||
|
||||
export type GeminiDispatchOptions = {
|
||||
prompt: string;
|
||||
/**
|
||||
* System-level instructions written to workspace/GEMINI.md before each invocation.
|
||||
* Gemini CLI reads GEMINI.md from cwd on every turn (including resumes) — it is NOT
|
||||
* stored in the session file — so updating it takes effect immediately.
|
||||
*/
|
||||
systemPrompt?: string;
|
||||
workspace: string;
|
||||
agentId?: string;
|
||||
resumeSessionId?: string;
|
||||
/** Gemini model override (e.g. "gemini-2.5-flash"). Defaults to gemini-cli's configured default. */
|
||||
model?: string;
|
||||
/** OpenClaw tool definitions to expose via MCP */
|
||||
openclawTools?: OpenAITool[];
|
||||
bridgePort?: number;
|
||||
bridgeApiKey?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Write the OpenClaw MCP server config to the workspace's .gemini/settings.json.
|
||||
*
|
||||
* Unlike Claude's --mcp-config flag, Gemini CLI reads MCP configuration from the
|
||||
* project settings file (.gemini/settings.json in the cwd). We write it before
|
||||
* each invocation to keep the tool list in sync with the current turn's tools.
|
||||
*
|
||||
* The file is merged with any existing settings to preserve user/project config.
|
||||
*/
|
||||
function setupGeminiMcpSettings(
|
||||
tools: OpenAITool[],
|
||||
bridgePort: number,
|
||||
bridgeApiKey: string,
|
||||
workspace: string,
|
||||
agentId: string,
|
||||
): void {
|
||||
const geminiDir = path.join(workspace, ".gemini");
|
||||
const settingsPath = path.join(geminiDir, "settings.json");
|
||||
|
||||
fs.mkdirSync(geminiDir, { recursive: true });
|
||||
|
||||
// Preserve existing non-MCP settings
|
||||
let existing: Record<string, unknown> = {};
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
try {
|
||||
existing = JSON.parse(fs.readFileSync(settingsPath, "utf8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
// corrupt settings — start fresh
|
||||
}
|
||||
}
|
||||
|
||||
if (!tools.length || !fs.existsSync(MCP_SERVER_SCRIPT)) {
|
||||
delete existing.mcpServers;
|
||||
} else {
|
||||
const tmpDir = os.tmpdir();
|
||||
const sessionId = `oc-${Date.now()}`;
|
||||
const toolDefsPath = path.join(tmpDir, `${sessionId}-tools.json`);
|
||||
fs.writeFileSync(toolDefsPath, JSON.stringify(tools, null, 2), "utf8");
|
||||
|
||||
// Gemini MCP server config — server alias must not contain underscores
|
||||
// (Gemini uses underscores as FQN separator: mcp_<alias>_<toolname>).
|
||||
// We use "openclaw" (no underscores) to keep names clean.
|
||||
existing.mcpServers = {
|
||||
openclaw: {
|
||||
command: process.execPath,
|
||||
args: [MCP_SERVER_SCRIPT],
|
||||
env: {
|
||||
TOOL_DEFS_FILE: toolDefsPath,
|
||||
BRIDGE_EXECUTE_URL: `http://127.0.0.1:${bridgePort}/mcp/execute`,
|
||||
BRIDGE_API_KEY: bridgeApiKey,
|
||||
WORKSPACE: workspace,
|
||||
AGENT_ID: agentId,
|
||||
},
|
||||
trust: true, // auto-approve MCP tool calls (equivalent to Claude's bypassPermissions)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2), "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a turn to Gemini CLI using `gemini -p --output-format stream-json`.
|
||||
* Returns an async iterable of ClaudeMessage events (same interface as Claude adapter).
|
||||
*
|
||||
* Stream-json event types from Gemini CLI:
|
||||
* init → { session_id, model }
|
||||
* message → { role: "assistant", content: string, delta: true } (streaming text)
|
||||
* tool_use → informational (Gemini handles internally)
|
||||
* tool_result → informational (Gemini handles internally)
|
||||
* result → { status, stats }
|
||||
*/
|
||||
export async function* dispatchToGemini(
|
||||
opts: GeminiDispatchOptions,
|
||||
): AsyncIterable<ClaudeMessage> {
|
||||
const {
|
||||
prompt,
|
||||
systemPrompt,
|
||||
workspace,
|
||||
agentId = "",
|
||||
resumeSessionId,
|
||||
model,
|
||||
openclawTools,
|
||||
bridgePort = 18800,
|
||||
bridgeApiKey = "",
|
||||
} = opts;
|
||||
|
||||
// Write system-level instructions to workspace/GEMINI.md every turn.
|
||||
// Gemini CLI reads GEMINI.md from cwd on every invocation (including resumes)
|
||||
// and does NOT store it in the session file, so this acts as a stateless system prompt.
|
||||
if (systemPrompt) {
|
||||
fs.writeFileSync(path.join(workspace, "GEMINI.md"), systemPrompt, "utf8");
|
||||
}
|
||||
|
||||
// Write MCP config to workspace .gemini/settings.json every turn.
|
||||
// Gemini CLI restarts the MCP server process each invocation (like Claude),
|
||||
// so we must re-inject the config on every turn including resumes.
|
||||
if (openclawTools?.length) {
|
||||
setupGeminiMcpSettings(openclawTools, bridgePort, bridgeApiKey, workspace, agentId);
|
||||
}
|
||||
|
||||
// NOTE: prompt goes right after -p before other flags to avoid ambiguity.
|
||||
const args: string[] = [
|
||||
"-p",
|
||||
prompt,
|
||||
"--output-format", "stream-json",
|
||||
"--approval-mode", "yolo",
|
||||
];
|
||||
|
||||
if (model) {
|
||||
args.push("-m", model);
|
||||
}
|
||||
|
||||
if (resumeSessionId) {
|
||||
args.push("--resume", resumeSessionId);
|
||||
}
|
||||
|
||||
const child = spawn("gemini", args, {
|
||||
cwd: workspace,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
const stderrLines: string[] = [];
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderrLines.push(chunk.toString("utf8").trim());
|
||||
});
|
||||
|
||||
const rl = createInterface({ input: child.stdout!, crlfDelay: Infinity });
|
||||
|
||||
let capturedSessionId = "";
|
||||
const events: ClaudeMessage[] = [];
|
||||
let done = false;
|
||||
let resolveNext: (() => void) | null = null;
|
||||
|
||||
rl.on("line", (line: string) => {
|
||||
if (!line.trim()) return;
|
||||
let event: Record<string, unknown>;
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
} catch {
|
||||
return; // ignore non-JSON lines (e.g. "YOLO mode is enabled." warning)
|
||||
}
|
||||
|
||||
const type = event.type as string;
|
||||
|
||||
// init event carries the session_id for future --resume
|
||||
if (type === "init") {
|
||||
const sid = event.session_id as string;
|
||||
if (sid) capturedSessionId = sid;
|
||||
}
|
||||
|
||||
// Streaming assistant text: message events with delta:true
|
||||
if (type === "message" && event.role === "assistant" && event.delta) {
|
||||
const content = event.content as string;
|
||||
if (content) {
|
||||
events.push({ type: "text", text: content });
|
||||
}
|
||||
}
|
||||
|
||||
if (resolveNext) {
|
||||
const r = resolveNext;
|
||||
resolveNext = null;
|
||||
r();
|
||||
}
|
||||
});
|
||||
|
||||
rl.on("close", () => {
|
||||
done = true;
|
||||
if (resolveNext) {
|
||||
const r = resolveNext;
|
||||
resolveNext = null;
|
||||
r();
|
||||
}
|
||||
});
|
||||
|
||||
while (true) {
|
||||
if (events.length > 0) {
|
||||
yield events.shift()!;
|
||||
continue;
|
||||
}
|
||||
if (done) break;
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveNext = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
while (events.length > 0) {
|
||||
yield events.shift()!;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
child.on("close", resolve);
|
||||
if (child.exitCode !== null) resolve();
|
||||
});
|
||||
|
||||
if (capturedSessionId) {
|
||||
yield { type: "done", sessionId: capturedSessionId };
|
||||
} else {
|
||||
const stderrSummary = stderrLines
|
||||
.join(" ")
|
||||
.replace(/YOLO mode is enabled\./g, "")
|
||||
.trim()
|
||||
.slice(0, 200);
|
||||
yield {
|
||||
type: "error",
|
||||
message: `gemini did not return a session_id${stderrSummary ? `: ${stderrSummary}` : ""}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import path from "node:path";
|
||||
type OpenClawConfig = Record<string, unknown>;
|
||||
type AgentEntry = Record<string, unknown>;
|
||||
|
||||
const CONTRACTOR_MODEL = "contractor-agent/contractor-claude-bridge";
|
||||
const CLAUDE_CONTRACTOR_MODEL = "contractor-agent/contractor-claude-bridge";
|
||||
const GEMINI_CONTRACTOR_MODEL = "contractor-agent/contractor-gemini-bridge";
|
||||
|
||||
function readConfig(): { config: OpenClawConfig; configPath: string } {
|
||||
const configPath = path.join(os.homedir(), ".openclaw", "openclaw.json");
|
||||
@@ -25,25 +26,33 @@ function writeConfig(configPath: string, config: OpenClawConfig): void {
|
||||
* We intentionally do NOT write a custom `runtime.type` — OpenClaw's schema only
|
||||
* allows "embedded" or "acp", and contractor agents are identified by their model.
|
||||
*/
|
||||
export function markAgentAsClaudeContractor(agentId: string, _workspace: string): void {
|
||||
const { config, configPath } = readConfig();
|
||||
|
||||
function markContractorAgent(agentId: string, expectedModel: string, configPath: string, config: OpenClawConfig): void {
|
||||
const agents = (config.agents as { list?: AgentEntry[] } | undefined)?.list;
|
||||
if (!agents) throw new Error("agents.list not found in openclaw.json");
|
||||
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
if (!agent) throw new Error(`agent ${agentId} not found in openclaw.json`);
|
||||
|
||||
if (agent.model !== CONTRACTOR_MODEL) {
|
||||
if (agent.model !== expectedModel) {
|
||||
throw new Error(
|
||||
`agent ${agentId} model is "${String(agent.model)}", expected "${CONTRACTOR_MODEL}"`,
|
||||
`agent ${agentId} model is "${String(agent.model)}", expected "${expectedModel}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure tools are enabled for this agent so OpenClaw includes tool definitions
|
||||
// in every chat completions request — needed for the MCP proxy to expose them to Claude.
|
||||
// Ensure tools are enabled so OpenClaw includes tool definitions in every
|
||||
// chat completions request — needed for the MCP proxy to expose them to Claude/Gemini.
|
||||
if (!agent.tools) {
|
||||
agent.tools = { profile: "full" };
|
||||
writeConfig(configPath, config);
|
||||
}
|
||||
}
|
||||
|
||||
export function markAgentAsClaudeContractor(agentId: string, _workspace: string): void {
|
||||
const { config, configPath } = readConfig();
|
||||
markContractorAgent(agentId, CLAUDE_CONTRACTOR_MODEL, configPath, config);
|
||||
}
|
||||
|
||||
export function markAgentAsGeminiContractor(agentId: string, _workspace: string): void {
|
||||
const { config, configPath } = readConfig();
|
||||
markContractorAgent(agentId, GEMINI_CONTRACTOR_MODEL, configPath, config);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
export type ContractorKind = "claude";
|
||||
export type ContractorKind = "claude" | "gemini";
|
||||
|
||||
export type ContractorAgentMetadata = {
|
||||
agentId: string;
|
||||
contractor: "claude";
|
||||
contractor: ContractorKind;
|
||||
bridgeModel: string;
|
||||
workspace: string;
|
||||
permissionMode: string;
|
||||
@@ -3,7 +3,8 @@ export type SessionMapState = "active" | "closed" | "orphaned";
|
||||
export type SessionMapEntry = {
|
||||
openclawSessionKey: string;
|
||||
agentId: string;
|
||||
contractor: "claude";
|
||||
contractor: "claude" | "gemini";
|
||||
/** Opaque session ID used to resume the underlying CLI session (--resume). */
|
||||
claudeSessionId: string;
|
||||
workspace: string;
|
||||
createdAt: string;
|
||||
@@ -2,10 +2,10 @@ import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { normalizePluginConfig } from "./types/contractor.js";
|
||||
import { resolveContractorAgentMetadata } from "./contractor/metadata-resolver.js";
|
||||
import { createBridgeServer } from "./bridge/server.js";
|
||||
import { registerCli } from "./cli/register-cli.js";
|
||||
import { normalizePluginConfig } from "./core/types/contractor.js";
|
||||
import { resolveContractorAgentMetadata } from "./core/contractor/metadata-resolver.js";
|
||||
import { createBridgeServer } from "./web/server.js";
|
||||
import { registerCli } from "./commands/register-cli.js";
|
||||
import type http from "node:http";
|
||||
|
||||
function isPortFree(port: number): Promise<boolean> {
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Contractor Agent",
|
||||
"version": "0.1.0",
|
||||
"description": "Turns Claude Code into an OpenClaw-managed contractor agent",
|
||||
"main": "src/index.ts",
|
||||
"main": "index.ts",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
14
plugin/package.json
Normal file
14
plugin/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "contractor-agent",
|
||||
"version": "0.1.0",
|
||||
"description": "OpenClaw plugin: turns Claude Code into an OpenClaw-managed contractor agent",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.101"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0",
|
||||
"openclaw": "*"
|
||||
}
|
||||
}
|
||||
117
plugin/web/bootstrap.ts
Normal file
117
plugin/web/bootstrap.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export type BootstrapInput = {
|
||||
agentId: string;
|
||||
openclawSessionKey: string;
|
||||
workspace: string;
|
||||
/** Skills XML block extracted from the OpenClaw system prompt, if any */
|
||||
skillsBlock?: string;
|
||||
/** Subset of OpenClaw context files present in the workspace (for persona/identity) */
|
||||
workspaceContextFiles?: string[];
|
||||
};
|
||||
|
||||
/** Read a workspace file. Returns the content, or null if missing/unreadable. */
|
||||
function readWorkspaceFile(workspace: string, filename: string): string | null {
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(workspace, filename), "utf8").trim();
|
||||
return content || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the one-time bootstrap message injected at the start of a new session.
|
||||
* Workspace context files (SOUL.md, IDENTITY.md, MEMORY.md, USER.md) are read
|
||||
* here and embedded inline — the agent must not need to fetch them separately.
|
||||
* Must NOT be re-injected on every turn.
|
||||
*/
|
||||
export function buildBootstrap(input: BootstrapInput): string {
|
||||
const contextFiles = input.workspaceContextFiles ?? [];
|
||||
|
||||
const lines = [
|
||||
`You are operating as a contractor agent inside OpenClaw.`,
|
||||
``,
|
||||
`## Context`,
|
||||
`- Agent ID: ${input.agentId}`,
|
||||
`- Session key: ${input.openclawSessionKey}`,
|
||||
`- Workspace: ${input.workspace}`,
|
||||
``,
|
||||
`## Role`,
|
||||
`You receive tasks from OpenClaw users and complete them using your tools.`,
|
||||
`You do not need to manage your own session context — OpenClaw handles session routing.`,
|
||||
`Your responses go directly back to the user through OpenClaw.`,
|
||||
``,
|
||||
`## Guidelines`,
|
||||
`- Work in the specified workspace directory.`,
|
||||
`- Be concise and action-oriented. Use tools to accomplish tasks rather than describing what you would do.`,
|
||||
`- Each message you receive contains the latest user request. Previous context is in your session memory.`,
|
||||
`- If a task is unclear, ask one focused clarifying question.`,
|
||||
];
|
||||
|
||||
// ── Persona (SOUL.md) ─────────────────────────────────────────────────────
|
||||
// Injected directly so the agent embodies the persona immediately without
|
||||
// needing to read files first.
|
||||
if (contextFiles.includes("SOUL.md")) {
|
||||
const soul = readWorkspaceFile(input.workspace, "SOUL.md");
|
||||
if (soul) {
|
||||
lines.push(``, `## Soul`, soul);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Identity (IDENTITY.md) ────────────────────────────────────────────────
|
||||
if (contextFiles.includes("IDENTITY.md")) {
|
||||
const identity = readWorkspaceFile(input.workspace, "IDENTITY.md");
|
||||
if (identity) {
|
||||
lines.push(``, `## Identity`, identity);
|
||||
// If the file still looks like the default template, encourage the agent
|
||||
// to fill it in.
|
||||
if (identity.includes("Fill this in during your first conversation")) {
|
||||
lines.push(
|
||||
``,
|
||||
`_IDENTITY.md is still a template. Pick a name, creature type, and vibe for yourself`,
|
||||
`and update the file at ${input.workspace}/IDENTITY.md._`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── User profile (USER.md) ────────────────────────────────────────────────
|
||||
if (contextFiles.includes("USER.md")) {
|
||||
const user = readWorkspaceFile(input.workspace, "USER.md");
|
||||
if (user) {
|
||||
lines.push(``, `## User Profile`, user);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Memory index (MEMORY.md) ──────────────────────────────────────────────
|
||||
if (contextFiles.includes("MEMORY.md") || contextFiles.some(f => f.startsWith("memory/"))) {
|
||||
const memoryIndex = readWorkspaceFile(input.workspace, "MEMORY.md");
|
||||
lines.push(``, `## Memory`);
|
||||
if (memoryIndex) {
|
||||
lines.push(memoryIndex);
|
||||
}
|
||||
lines.push(
|
||||
``,
|
||||
`Memory files live in ${input.workspace}/memory/. Use the Read tool to fetch individual`,
|
||||
`memories and write new ones to ${input.workspace}/memory/<topic>.md.`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Skills ────────────────────────────────────────────────────────────────
|
||||
if (input.skillsBlock) {
|
||||
lines.push(
|
||||
``,
|
||||
`## Skills`,
|
||||
`The following skills are available. When a task matches a skill's description:`,
|
||||
`1. Read the skill's SKILL.md using the Read tool (the <location> field is the absolute path).`,
|
||||
`2. Follow the instructions in SKILL.md. Replace \`{baseDir}\` with the directory containing SKILL.md.`,
|
||||
`3. Run scripts using the Bash tool, NOT the \`exec\` tool (you have Bash, not exec).`,
|
||||
``,
|
||||
input.skillsBlock,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { BridgeInboundRequest, OpenAIMessage } from "../types/model.js";
|
||||
import type { BridgeInboundRequest, OpenAIMessage } from "../core/types/model.js";
|
||||
|
||||
function messageText(m: OpenAIMessage): string {
|
||||
if (typeof m.content === "string") return m.content;
|
||||
@@ -1,15 +1,16 @@
|
||||
import http from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { BridgeInboundRequest } from "../types/model.js";
|
||||
import type { BridgeInboundRequest } from "../core/types/model.js";
|
||||
import { extractLatestUserMessage, extractRequestContext } from "./input-filter.js";
|
||||
import { buildBootstrap } from "./bootstrap.js";
|
||||
import { dispatchToClaude } from "../claude/sdk-adapter.js";
|
||||
import { dispatchToClaude } from "../core/claude/sdk-adapter.js";
|
||||
import { dispatchToGemini } from "../core/gemini/sdk-adapter.js";
|
||||
import { getGlobalPluginRegistry } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import {
|
||||
getSession,
|
||||
putSession,
|
||||
markOrphaned,
|
||||
} from "../contractor/session-map-store.js";
|
||||
} from "../core/contractor/session-map-store.js";
|
||||
|
||||
export type BridgeServerConfig = {
|
||||
port: number;
|
||||
@@ -129,15 +130,24 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
|
||||
workspace = "/tmp";
|
||||
}
|
||||
|
||||
// Look up existing Claude session
|
||||
let existingEntry = sessionKey ? getSession(workspace, sessionKey) : null;
|
||||
let claudeSessionId = existingEntry?.state === "active" ? existingEntry.claudeSessionId : null;
|
||||
// Detect backend from body.model: "contractor-gemini-bridge" → Gemini, else → Claude
|
||||
const isGemini = typeof body.model === "string" && body.model.includes("gemini");
|
||||
|
||||
// Build prompt: bootstrap on first turn, bare message on subsequent turns
|
||||
const isFirstTurn = !claudeSessionId;
|
||||
const prompt = isFirstTurn
|
||||
? `${buildBootstrap({ agentId, openclawSessionKey: sessionKey, workspace, skillsBlock: skillsBlock || undefined, workspaceContextFiles })}\n\n---\n\n${latestMessage}`
|
||||
: latestMessage;
|
||||
// Look up existing session (shared structure for both Claude and Gemini)
|
||||
let existingEntry = sessionKey ? getSession(workspace, sessionKey) : null;
|
||||
let resumeSessionId = existingEntry?.state === "active" ? existingEntry.claudeSessionId : null;
|
||||
|
||||
// Bootstrap is passed as the system prompt on every turn (stateless — not persisted in session files).
|
||||
// For Claude: --system-prompt fully replaces any prior system prompt each invocation.
|
||||
// For Gemini: written to workspace/GEMINI.md, read dynamically by Gemini CLI each turn.
|
||||
// This keeps persona and skills current without needing to track first-turn state.
|
||||
const systemPrompt = buildBootstrap({
|
||||
agentId,
|
||||
openclawSessionKey: sessionKey,
|
||||
workspace,
|
||||
skillsBlock: skillsBlock || undefined,
|
||||
workspaceContextFiles,
|
||||
});
|
||||
|
||||
// Start SSE response
|
||||
res.writeHead(200, {
|
||||
@@ -154,22 +164,36 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
|
||||
const openclawTools = body.tools ?? [];
|
||||
|
||||
try {
|
||||
for await (const event of dispatchToClaude({
|
||||
prompt,
|
||||
workspace,
|
||||
agentId,
|
||||
resumeSessionId: claudeSessionId ?? undefined,
|
||||
permissionMode,
|
||||
openclawTools, // pass every turn — MCP server exits with the claude process
|
||||
bridgePort: port,
|
||||
bridgeApiKey: apiKey,
|
||||
})) {
|
||||
const dispatchIter = isGemini
|
||||
? dispatchToGemini({
|
||||
prompt: latestMessage,
|
||||
systemPrompt,
|
||||
workspace,
|
||||
agentId,
|
||||
resumeSessionId: resumeSessionId ?? undefined,
|
||||
openclawTools,
|
||||
bridgePort: port,
|
||||
bridgeApiKey: apiKey,
|
||||
})
|
||||
: dispatchToClaude({
|
||||
prompt: latestMessage,
|
||||
systemPrompt,
|
||||
workspace,
|
||||
agentId,
|
||||
resumeSessionId: resumeSessionId ?? undefined,
|
||||
permissionMode,
|
||||
openclawTools,
|
||||
bridgePort: port,
|
||||
bridgeApiKey: apiKey,
|
||||
});
|
||||
|
||||
for await (const event of dispatchIter) {
|
||||
if (event.type === "text") {
|
||||
sseWrite(res, buildChunk(completionId, event.text));
|
||||
} else if (event.type === "done") {
|
||||
newSessionId = event.sessionId;
|
||||
} else if (event.type === "error") {
|
||||
logger.warn(`[contractor-bridge] claude error: ${event.message}`);
|
||||
logger.warn(`[contractor-bridge] ${isGemini ? "gemini" : "claude"} error: ${event.message}`);
|
||||
hasError = true;
|
||||
sseWrite(res, buildChunk(completionId, `[contractor-bridge error: ${event.message}]`));
|
||||
}
|
||||
@@ -184,13 +208,13 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
|
||||
sseWrite(res, "[DONE]");
|
||||
res.end();
|
||||
|
||||
// Persist session mapping
|
||||
// Persist session mapping (shared for both Claude and Gemini)
|
||||
if (newSessionId && sessionKey && !hasError) {
|
||||
const now = new Date().toISOString();
|
||||
putSession(workspace, {
|
||||
openclawSessionKey: sessionKey,
|
||||
agentId,
|
||||
contractor: "claude",
|
||||
contractor: isGemini ? "gemini" : "claude",
|
||||
claudeSessionId: newSessionId,
|
||||
workspace,
|
||||
createdAt: existingEntry?.createdAt ?? now,
|
||||
@@ -198,7 +222,7 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
|
||||
state: "active",
|
||||
});
|
||||
logger.info(
|
||||
`[contractor-bridge] session mapped sessionKey=${sessionKey} claudeSessionId=${newSessionId}`,
|
||||
`[contractor-bridge] session mapped sessionKey=${sessionKey} contractor=${isGemini ? "gemini" : "claude"} sessionId=${newSessionId}`,
|
||||
);
|
||||
} else if (hasError && sessionKey && existingEntry) {
|
||||
markOrphaned(workspace, sessionKey);
|
||||
@@ -233,6 +257,12 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: "contractor-agent",
|
||||
},
|
||||
{
|
||||
id: "contractor-gemini-bridge",
|
||||
object: "model",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: "contractor-agent",
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
@@ -266,7 +296,7 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
|
||||
}
|
||||
|
||||
// MCP proxy tool execution callback.
|
||||
// Called by openclaw-mcp-server.mjs when Claude Code invokes an MCP tool.
|
||||
// Called by services/openclaw-mcp-server.mjs when Claude Code invokes an MCP tool.
|
||||
// Relays the call to OpenClaw's actual tool implementation via the global plugin registry.
|
||||
if (method === "POST" && url === "/mcp/execute") {
|
||||
let body: { tool?: string; args?: Record<string, unknown>; workspace?: string; agentId?: string };
|
||||
@@ -46,31 +46,16 @@ function install() {
|
||||
}
|
||||
fs.mkdirSync(PLUGIN_INSTALL_DIR, { recursive: true });
|
||||
|
||||
// OpenClaw expects the plugin entry (index.ts) at the plugin root,
|
||||
// matching the convention used by existing plugins like dirigent.
|
||||
// We flatten src/ to the plugin root and copy supporting files alongside it.
|
||||
// Copy plugin/ contents to the install root.
|
||||
// plugin/ contains index.ts, openclaw.plugin.json, package.json, and subdirs.
|
||||
const pluginDir = path.join(PROJECT_ROOT, "plugin");
|
||||
fs.cpSync(pluginDir, PLUGIN_INSTALL_DIR, { recursive: true });
|
||||
|
||||
// Flatten src/ → plugin root
|
||||
const srcDir = path.join(PROJECT_ROOT, "src");
|
||||
fs.cpSync(srcDir, PLUGIN_INSTALL_DIR, { recursive: true });
|
||||
|
||||
// Copy package.json (update main to point to index.ts at root)
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, "package.json"), "utf8"));
|
||||
pkg.main = "index.ts";
|
||||
fs.writeFileSync(
|
||||
path.join(PLUGIN_INSTALL_DIR, "package.json"),
|
||||
JSON.stringify(pkg, null, 2) + "\n",
|
||||
);
|
||||
|
||||
// Copy openclaw.plugin.json — set main to root index.ts
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(PROJECT_ROOT, "openclaw.plugin.json"), "utf8"),
|
||||
);
|
||||
manifest.main = "index.ts";
|
||||
fs.writeFileSync(
|
||||
path.join(PLUGIN_INSTALL_DIR, "openclaw.plugin.json"),
|
||||
JSON.stringify(manifest, null, 2) + "\n",
|
||||
);
|
||||
// Copy services/ (standalone processes used by the plugin) into the install root.
|
||||
const servicesDir = path.join(PROJECT_ROOT, "services");
|
||||
if (fs.existsSync(servicesDir)) {
|
||||
fs.cpSync(servicesDir, path.join(PLUGIN_INSTALL_DIR, "services"), { recursive: true });
|
||||
}
|
||||
|
||||
// 2. Install npm dependencies inside the plugin dir
|
||||
console.log(`[install] Installing npm dependencies...`);
|
||||
@@ -99,6 +84,15 @@ function install() {
|
||||
contextWindow: 200000,
|
||||
maxTokens: 16000,
|
||||
},
|
||||
{
|
||||
id: "contractor-gemini-bridge",
|
||||
name: "Contractor Gemini Bridge",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 16000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
export type BootstrapInput = {
|
||||
agentId: string;
|
||||
openclawSessionKey: string;
|
||||
workspace: string;
|
||||
/** Skills XML block extracted from the OpenClaw system prompt, if any */
|
||||
skillsBlock?: string;
|
||||
/** Subset of OpenClaw context files present in the workspace (for persona/identity) */
|
||||
workspaceContextFiles?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the one-time bootstrap message injected at the start of a new Claude session.
|
||||
* This tells Claude it is operating as an OpenClaw contractor agent.
|
||||
* Must NOT be re-injected on every turn.
|
||||
*/
|
||||
export function buildBootstrap(input: BootstrapInput): string {
|
||||
const contextFiles = input.workspaceContextFiles ?? [];
|
||||
const hasSoul = contextFiles.includes("SOUL.md");
|
||||
const hasIdentity = contextFiles.includes("IDENTITY.md");
|
||||
|
||||
const lines = [
|
||||
`You are operating as a contractor agent inside OpenClaw.`,
|
||||
``,
|
||||
`## Context`,
|
||||
`- Agent ID: ${input.agentId}`,
|
||||
`- Session key: ${input.openclawSessionKey}`,
|
||||
`- Workspace: ${input.workspace}`,
|
||||
``,
|
||||
`## Role`,
|
||||
`You receive tasks from OpenClaw users and complete them using your tools.`,
|
||||
`You do not need to manage your own session context — OpenClaw handles session routing.`,
|
||||
`Your responses go directly back to the user through OpenClaw.`,
|
||||
``,
|
||||
`## Guidelines`,
|
||||
`- Work in the specified workspace directory.`,
|
||||
`- Be concise and action-oriented. Use tools to accomplish tasks rather than describing what you would do.`,
|
||||
`- Each message you receive contains the latest user request. Previous context is in your session memory.`,
|
||||
`- If a task is unclear, ask one focused clarifying question.`,
|
||||
];
|
||||
|
||||
// Inject persona/identity context from OpenClaw workspace files.
|
||||
// These files define who this agent is and how it should behave.
|
||||
if (hasSoul || hasIdentity) {
|
||||
lines.push(``, `## Persona & Identity`);
|
||||
if (hasSoul) {
|
||||
lines.push(
|
||||
`Read ${input.workspace}/SOUL.md now (using the Read tool) and embody the persona and tone it describes.`,
|
||||
`SOUL.md defines your values, communication style, and boundaries — treat it as authoritative.`,
|
||||
);
|
||||
}
|
||||
if (hasIdentity) {
|
||||
lines.push(
|
||||
`Read ${input.workspace}/IDENTITY.md now (using the Read tool) to understand your name, creature type, and personal vibe.`,
|
||||
`If IDENTITY.md is empty/template-only, you may fill it in as you develop your identity.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Memory system note: OpenClaw uses workspace/memory/*.md + MEMORY.md.
|
||||
// Claude Code also maintains per-project auto-memory at ~/.claude/projects/<path>/memory/.
|
||||
// Both are active; Claude Code's auto-memory is loaded automatically each session.
|
||||
// OpenClaw memory tools (memory_search, memory_get) are available as MCP tools
|
||||
// but require bridge-side implementations to execute (not yet wired).
|
||||
if (contextFiles.includes("MEMORY.md") || contextFiles.some(f => f.startsWith("memory/"))) {
|
||||
lines.push(
|
||||
``,
|
||||
`## Memory`,
|
||||
`OpenClaw memory files exist in ${input.workspace}/memory/ and ${input.workspace}/MEMORY.md.`,
|
||||
`Use the Read tool to access these directly. For writing new memories, write to ${input.workspace}/memory/<topic>.md.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.skillsBlock) {
|
||||
lines.push(
|
||||
``,
|
||||
`## Skills`,
|
||||
`The following skills are available. When a task matches a skill's description:`,
|
||||
`1. Read the skill's SKILL.md using the Read tool (the <location> field is the absolute path).`,
|
||||
`2. Follow the instructions in SKILL.md. Replace \`{baseDir}\` with the directory containing SKILL.md.`,
|
||||
`3. Run scripts using the Bash tool, NOT the \`exec\` tool (you have Bash, not exec).`,
|
||||
``,
|
||||
input.skillsBlock,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
54
test/contractor-probe/index.ts
Normal file
54
test/contractor-probe/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
|
||||
const _G = globalThis as Record<string, unknown>;
|
||||
const LIFECYCLE_KEY = "_contractorProbeLifecycleRegistered";
|
||||
const SIDECAR_KEY = "_contractorProbeSidecarProcess";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function startSidecar(logger: OpenClawPluginApi["logger"]): void {
|
||||
const serverPath = path.join(__dirname, "server.mjs");
|
||||
const child = spawn(process.execPath, [serverPath], {
|
||||
env: { ...process.env, PROBE_PORT: "8799" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
child.stdout?.on("data", (d: Buffer) =>
|
||||
logger.info(`[contractor-probe-sidecar] ${d.toString().trim()}`)
|
||||
);
|
||||
child.stderr?.on("data", (d: Buffer) =>
|
||||
logger.warn(`[contractor-probe-sidecar] ${d.toString().trim()}`)
|
||||
);
|
||||
child.on("exit", (code: number | null) =>
|
||||
logger.info(`[contractor-probe-sidecar] exited code=${code}`)
|
||||
);
|
||||
|
||||
_G[SIDECAR_KEY] = child;
|
||||
logger.info("[contractor-probe] sidecar started");
|
||||
}
|
||||
|
||||
function stopSidecar(logger: OpenClawPluginApi["logger"]): void {
|
||||
const child = _G[SIDECAR_KEY] as import("node:child_process").ChildProcess | undefined;
|
||||
if (child && !child.killed) {
|
||||
child.kill("SIGTERM");
|
||||
logger.info("[contractor-probe] sidecar stopped");
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
id: "contractor-probe",
|
||||
name: "Contractor Probe",
|
||||
register(api: OpenClawPluginApi) {
|
||||
if (!_G[LIFECYCLE_KEY]) {
|
||||
_G[LIFECYCLE_KEY] = true;
|
||||
startSidecar(api.logger);
|
||||
api.on("gateway_stop", () => stopSidecar(api.logger));
|
||||
}
|
||||
|
||||
api.logger.info("[contractor-probe] plugin registered");
|
||||
},
|
||||
};
|
||||
8
test/contractor-probe/openclaw.plugin.json
Normal file
8
test/contractor-probe/openclaw.plugin.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "contractor-probe",
|
||||
"name": "Contractor Probe",
|
||||
"version": "0.1.0",
|
||||
"description": "Test plugin to inspect custom model provider payloads",
|
||||
"main": "index.ts",
|
||||
"configSchema": {}
|
||||
}
|
||||
6
test/contractor-probe/package.json
Normal file
6
test/contractor-probe/package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "contractor-probe",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "index.ts"
|
||||
}
|
||||
166
test/contractor-probe/server.mjs
Normal file
166
test/contractor-probe/server.mjs
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* contractor-probe sidecar server
|
||||
*
|
||||
* Acts as an OpenAI-compatible model provider for "contractor-probe-bridge".
|
||||
* Every request is logged in full to /tmp/contractor-probe-requests.jsonl so
|
||||
* we can inspect exactly what OpenClaw sends to a custom model provider.
|
||||
* The response echoes back a JSON summary of what was received.
|
||||
*/
|
||||
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
|
||||
const PORT = Number(process.env.PROBE_PORT || 8799);
|
||||
const LOG_FILE = process.env.PROBE_LOG || "/tmp/contractor-probe-requests.jsonl";
|
||||
const MODEL_ID = "contractor-probe-bridge";
|
||||
|
||||
function sendJson(res, status, payload) {
|
||||
const body = JSON.stringify(payload, null, 2);
|
||||
res.writeHead(status, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function logRequest(entry) {
|
||||
try {
|
||||
fs.appendFileSync(LOG_FILE, JSON.stringify(entry) + "\n");
|
||||
} catch (err) {
|
||||
console.error("[contractor-probe] failed to write log:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function buildChatCompletionResponse(reqBody, summary) {
|
||||
return {
|
||||
id: `chatcmpl_probe_${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: reqBody?.model || MODEL_ID,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: `[contractor-probe] Received request:\n${JSON.stringify(summary, null, 2)}`,
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 0, completion_tokens: 10, total_tokens: 10 },
|
||||
};
|
||||
}
|
||||
|
||||
function buildResponsesResponse(reqBody, summary) {
|
||||
return {
|
||||
id: `resp_probe_${Date.now()}`,
|
||||
object: "response",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
model: reqBody?.model || MODEL_ID,
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "output_text",
|
||||
text: `[contractor-probe] Received request:\n${JSON.stringify(summary, null, 2)}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
usage: { input_tokens: 0, output_tokens: 10, total_tokens: 10 },
|
||||
};
|
||||
}
|
||||
|
||||
function listModels() {
|
||||
return {
|
||||
object: "list",
|
||||
data: [
|
||||
{
|
||||
id: MODEL_ID,
|
||||
object: "model",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: "contractor-probe",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = req.url ?? "/";
|
||||
const method = req.method ?? "GET";
|
||||
|
||||
if (method === "GET" && url === "/health") {
|
||||
return sendJson(res, 200, { ok: true, service: "contractor-probe", port: PORT });
|
||||
}
|
||||
|
||||
if (method === "GET" && url === "/v1/models") {
|
||||
return sendJson(res, 200, listModels());
|
||||
}
|
||||
|
||||
if (method !== "POST") {
|
||||
return sendJson(res, 404, { error: "not_found" });
|
||||
}
|
||||
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
if (body.length > 2_000_000) req.destroy();
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
let parsed = {};
|
||||
try {
|
||||
parsed = body ? JSON.parse(body) : {};
|
||||
} catch {
|
||||
return sendJson(res, 400, { error: "invalid_json" });
|
||||
}
|
||||
|
||||
const entry = {
|
||||
ts: new Date().toISOString(),
|
||||
method,
|
||||
url,
|
||||
headers: req.headers,
|
||||
body: parsed,
|
||||
};
|
||||
logRequest(entry);
|
||||
console.log(`[contractor-probe] ${method} ${url} — ${new Date().toISOString()}`);
|
||||
console.log(`[contractor-probe] messages count: ${parsed?.messages?.length ?? 0}`);
|
||||
if (parsed?.messages?.length) {
|
||||
const last = parsed.messages[parsed.messages.length - 1];
|
||||
console.log(`[contractor-probe] last message role=${last.role} content=${JSON.stringify(last.content)?.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
// Build a summary to echo back in the response
|
||||
const summary = {
|
||||
model: parsed.model,
|
||||
messagesCount: parsed.messages?.length ?? 0,
|
||||
lastMessage: parsed.messages?.[parsed.messages.length - 1] ?? null,
|
||||
firstSystemContent: parsed.messages?.find(m => m.role === "system")?.content?.toString()?.substring(0, 300) ?? null,
|
||||
stream: parsed.stream ?? false,
|
||||
temperature: parsed.temperature,
|
||||
max_tokens: parsed.max_tokens,
|
||||
};
|
||||
|
||||
if (url === "/v1/chat/completions") {
|
||||
return sendJson(res, 200, buildChatCompletionResponse(parsed, summary));
|
||||
}
|
||||
|
||||
if (url === "/v1/responses") {
|
||||
return sendJson(res, 200, buildResponsesResponse(parsed, summary));
|
||||
}
|
||||
|
||||
return sendJson(res, 404, { error: "not_found" });
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`[contractor-probe] listening on 127.0.0.1:${PORT}`);
|
||||
console.log(`[contractor-probe] logging requests to ${LOG_FILE}`);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
console.log("[contractor-probe] shutting down");
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
@@ -7,11 +7,11 @@
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"rootDir": "plugin",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"include": ["plugin/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test", "scripts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user