fix(openclaw): stop passing unsupported flags and actually deliver AgentInstructions (#1362)

Fixes #1332.

Two regressions introduced in #910 (2026-04-14, "OpenClaw backend P0+P1
improvements") that together block all openclaw users:

1. `openclaw agent` does not accept `--model` or `--system-prompt`, so
   any agent configured with a Model field crashed in ~700ms with
   `exit status 1`. Remove both forwards, and add them to
   openclawBlockedArgs so custom_args can't reintroduce the crash.
   Model is bound at registration time via `openclaw agents
   add/update --model`.

2. AgentInstructions were written to `{workDir}/AGENTS.md` by
   execenv.InjectRuntimeConfig, but openclaw loads bootstrap files
   from its own workspace dir — the file was never read, so every
   agent's Instructions field was silently discarded. Populate
   opts.SystemPrompt for the openclaw provider in runTask and
   prepend it to the `--message` payload in the backend so the
   model actually receives the instructions.

Other providers surface instructions through their native runtime
config file (CLAUDE.md / AGENTS.md / GEMINI.md) and are intentionally
left unchanged to avoid double injection.

Extract buildOpenclawArgs so arg construction is directly testable;
add unit tests covering the removed flags, the SystemPrompt prepend,
and custom_args filtering.
This commit is contained in:
Bohan Jiang
2026-04-20 14:01:41 +08:00
committed by GitHub
parent 5fa1da448f
commit bd445782d5
3 changed files with 178 additions and 16 deletions

View File

@@ -1026,6 +1026,15 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
CustomArgs: customArgs,
McpConfig: mcpConfig,
}
// openclaw loads its bootstrap files (AGENTS.md, SOUL.md, ...) from its own
// workspace dir rather than the task workdir, so the AGENTS.md written by
// execenv.InjectRuntimeConfig is never read. Pass agent instructions inline
// via SystemPrompt so the backend can prepend them to the --message payload.
// Other providers already surface instructions through their runtime config
// file and don't need this.
if provider == "openclaw" {
execOpts.SystemPrompt = instructions
}
result, tools, err := d.executeAndDrain(ctx, backend, prompt, execOpts, taskLog, task.ID)
if err != nil {

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
"log/slog"
"os/exec"
"strings"
"time"
@@ -14,10 +15,12 @@ import (
// openclawBlockedArgs are flags hardcoded by the daemon that must not be
// overridden by user-configured custom_args.
var openclawBlockedArgs = map[string]blockedArgMode{
"--local": blockedStandalone, // local mode for daemon execution
"--json": blockedStandalone, // JSON output for daemon communication
"--session-id": blockedWithValue, // managed by daemon for session resumption
"--message": blockedWithValue, // prompt is set by daemon
"--local": blockedStandalone, // local mode for daemon execution
"--json": blockedStandalone, // JSON output for daemon communication
"--session-id": blockedWithValue, // managed by daemon for session resumption
"--message": blockedWithValue, // prompt is set by daemon
"--model": blockedWithValue, // openclaw agent does not accept --model; model is bound at registration via `openclaw agents add/update --model`
"--system-prompt": blockedWithValue, // openclaw agent does not accept --system-prompt; instructions are injected into --message
}
// openclawBackend implements Backend by spawning `openclaw agent --message <prompt>
@@ -46,18 +49,7 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
if sessionID == "" {
sessionID = fmt.Sprintf("multica-%d", time.Now().UnixNano())
}
args := []string{"agent", "--local", "--json", "--session-id", sessionID}
if opts.Model != "" {
args = append(args, "--model", opts.Model)
}
if opts.SystemPrompt != "" {
args = append(args, "--system-prompt", opts.SystemPrompt)
}
if opts.Timeout > 0 {
args = append(args, "--timeout", fmt.Sprintf("%d", int(opts.Timeout.Seconds())))
}
args = append(args, filterCustomArgs(opts.CustomArgs, openclawBlockedArgs, b.cfg.Logger)...)
args = append(args, "--message", prompt)
args := buildOpenclawArgs(prompt, sessionID, opts, b.cfg.Logger)
cmd := exec.CommandContext(runCtx, execPath, args...)
b.cfg.Logger.Debug("agent command", "exec", execPath, "args", args)
@@ -141,6 +133,28 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
return &Session{Messages: msgCh, Result: resCh}, nil
}
// buildOpenclawArgs assembles the argv for a one-shot `openclaw agent` invocation.
//
// The CLI only accepts --local, --json, --session-id, --timeout, --message (and
// flags like --agent / --channel that users pass through CustomArgs). Notably
// it does NOT accept --model or --system-prompt — model is bound at agent
// registration time via `openclaw agents add/update --model`, and instructions
// must be injected inline into --message because openclaw loads AGENTS.md from
// its own workspace directory, not from cwd.
func buildOpenclawArgs(prompt, sessionID string, opts ExecOptions, logger *slog.Logger) []string {
args := []string{"agent", "--local", "--json", "--session-id", sessionID}
if opts.Timeout > 0 {
args = append(args, "--timeout", fmt.Sprintf("%d", int(opts.Timeout.Seconds())))
}
args = append(args, filterCustomArgs(opts.CustomArgs, openclawBlockedArgs, logger)...)
if opts.SystemPrompt != "" {
prompt = opts.SystemPrompt + "\n\n" + prompt
}
args = append(args, "--message", prompt)
return args
}
// ── Event handlers ──
// openclawEventResult holds accumulated state from processing the event stream.

View File

@@ -5,6 +5,7 @@ import (
"log/slog"
"strings"
"testing"
"time"
)
func TestNewReturnsOpenclawBackend(t *testing.T) {
@@ -913,3 +914,141 @@ func TestOpenclawInt64Nil(t *testing.T) {
t.Errorf("got %d, want 0", got)
}
}
// ── buildOpenclawArgs tests ──
// indexOf returns the first index of s in args, or -1 if absent.
func indexOf(args []string, s string) int {
for i, a := range args {
if a == s {
return i
}
}
return -1
}
func TestBuildOpenclawArgsMinimal(t *testing.T) {
t.Parallel()
args := buildOpenclawArgs("do work", "ses-1", ExecOptions{}, slog.Default())
expected := []string{"agent", "--local", "--json", "--session-id", "ses-1", "--message", "do work"}
if len(args) != len(expected) {
t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args)
}
for i, want := range expected {
if args[i] != want {
t.Errorf("args[%d] = %q, want %q", i, args[i], want)
}
}
}
func TestBuildOpenclawArgsDoesNotForwardModelOrSystemPrompt(t *testing.T) {
t.Parallel()
// openclaw agent rejects --model and --system-prompt; verify they are
// never emitted as flags even when Model and SystemPrompt are set.
args := buildOpenclawArgs("task", "ses-2", ExecOptions{
Model: "gpt-4o",
SystemPrompt: "You are a helpful agent.",
}, slog.Default())
if idx := indexOf(args, "--model"); idx != -1 {
t.Fatalf("unexpected --model flag at %d: %v", idx, args)
}
if idx := indexOf(args, "--system-prompt"); idx != -1 {
t.Fatalf("unexpected --system-prompt flag at %d: %v", idx, args)
}
}
func TestBuildOpenclawArgsPrependsSystemPromptToMessage(t *testing.T) {
t.Parallel()
args := buildOpenclawArgs("do the thing", "ses-3", ExecOptions{
SystemPrompt: "You are a read-only agent.",
}, slog.Default())
msgIdx := indexOf(args, "--message")
if msgIdx == -1 || msgIdx+1 >= len(args) {
t.Fatalf("expected --message <value> in args: %v", args)
}
got := args[msgIdx+1]
want := "You are a read-only agent.\n\ndo the thing"
if got != want {
t.Errorf("--message payload mismatch:\n got: %q\n want: %q", got, want)
}
}
func TestBuildOpenclawArgsEmptySystemPromptLeavesMessageUnchanged(t *testing.T) {
t.Parallel()
args := buildOpenclawArgs("just do it", "ses-4", ExecOptions{}, slog.Default())
msgIdx := indexOf(args, "--message")
if msgIdx == -1 || msgIdx+1 >= len(args) {
t.Fatalf("expected --message <value> in args: %v", args)
}
if got := args[msgIdx+1]; got != "just do it" {
t.Errorf("--message payload: got %q, want %q", got, "just do it")
}
}
func TestBuildOpenclawArgsTimeout(t *testing.T) {
t.Parallel()
args := buildOpenclawArgs("task", "ses-5", ExecOptions{
Timeout: 90 * time.Second,
}, slog.Default())
idx := indexOf(args, "--timeout")
if idx == -1 || idx+1 >= len(args) {
t.Fatalf("expected --timeout <value> in args: %v", args)
}
if got := args[idx+1]; got != "90" {
t.Errorf("--timeout value: got %q, want %q", got, "90")
}
}
func TestBuildOpenclawArgsFiltersBlockedCustomArgs(t *testing.T) {
t.Parallel()
// Users must not be able to re-introduce the banned flags via custom_args —
// they would crash `openclaw agent` just like the direct forward did.
args := buildOpenclawArgs("task", "ses-6", ExecOptions{
CustomArgs: []string{
"--agent", "research-bot",
"--model", "gpt-4o",
"--system-prompt", "You are helpful",
"--session-id", "hijacked",
"--message", "hijacked",
},
}, slog.Default())
if idx := indexOf(args, "--model"); idx != -1 {
t.Errorf("--model should be filtered from custom_args: %v", args)
}
if idx := indexOf(args, "--system-prompt"); idx != -1 {
t.Errorf("--system-prompt should be filtered from custom_args: %v", args)
}
// Whitelisted pass-through flag must survive filtering.
if idx := indexOf(args, "--agent"); idx == -1 || idx+1 >= len(args) || args[idx+1] != "research-bot" {
t.Errorf("expected --agent research-bot to survive filtering: %v", args)
}
// --session-id and --message appear exactly once — the daemon-managed ones.
if count := countOccurrences(args, "--session-id"); count != 1 {
t.Errorf("expected 1 --session-id (daemon-managed), got %d: %v", count, args)
}
if count := countOccurrences(args, "--message"); count != 1 {
t.Errorf("expected 1 --message (daemon-managed), got %d: %v", count, args)
}
}
func countOccurrences(args []string, s string) int {
n := 0
for _, a := range args {
if a == s {
n++
}
}
return n
}