mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
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:
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user