Files
multica/server/pkg/agent/pi.go
Bohan Jiang 0066ab259e refactor(agent): drop unreachable inline system-prompt branches (MUL-5392) (#6050)
* refactor(agent): drop unreachable inline system-prompt branches (MUL-5392)

The daemon only populates ExecOptions.SystemPrompt for openclaw, kimi and
traecli (providerNeedsInlineSystemPrompt); every other backend receives the
runtime brief as a per-task context file in the workdir. The inline branches in
claude, codex, opencode and pi were therefore dead, and read as if they were the
live delivery path.

Probed each backend over its real launch path with a canary in the context file
and no inline delivery — claude 2.1.220 (CLAUDE.md), codex 0.144.6 via the
app-server, opencode 1.17.7, pi 0.67.2, hermes 0.18.2 via ACP — and all of them
picked the brief up from disk, so the branches were removable with no behaviour
change. An empty workdir returned no canary, confirming the probe could fail.

opencode's branch was worse than dead: `opencode run` has no --prompt flag, so
enabling inline delivery there would have made every opencode task exit 1 with a
usage dump. The DevEco backend, forked from opencode, already documents this
constraint; opencode itself never got the fix.

Regression tests pin all three arg builders against re-adding the flag, and
providerNeedsInlineSystemPrompt now documents what was verified and what is
still unprobed (grok, qoder, codebuddy).

Hermes and kiro are untouched: their exclusion is deliberate and already tested.

Co-authored-by: multica-agent <github@multica.ai>

* test(agent): pin codex developerInstructions contract, drop stale pi flag doc

Review follow-up on MUL-5392.

buildPiArgs' doc comment still advertised --append-system-prompt after the
branch that emitted it was removed — exactly the stale-signal this PR set out
to delete.

The two codex sites fixed to a literal nil had no regression test, so restoring
nilIfEmpty(opts.SystemPrompt) would still have gone green. Both thread/start and
thread/resume now run with a canary SystemPrompt and assert developerInstructions
comes through as an explicit null. Mutation-checked: reverting either site fails
its test.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 22:02:55 +08:00

579 lines
16 KiB
Go

package agent
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
// piBackend implements Backend by spawning the Pi CLI in non-interactive
// JSON mode (`pi -p --mode json --session <path>`) and parsing its event
// stream on stdout.
type piBackend struct {
cfg Config
}
var (
piControlTokenRE = regexp.MustCompile(`<\|[A-Za-z0-9_-]+>[A-Za-z0-9_-]*|<[A-Za-z0-9_-]+\|>`)
)
func stripPiToolCallMarkup(s string) string {
s = stripPiStructuredToolMarkup(s)
return piControlTokenRE.ReplaceAllString(s, "")
}
func drainPiTextBuffer(buf *strings.Builder, delta string) string {
buf.WriteString(delta)
emit, pending := drainPiSanitizedText(buf.String())
buf.Reset()
buf.WriteString(pending)
return emit
}
func flushPiTextBuffer(buf *strings.Builder) string {
s := buf.String()
buf.Reset()
emit, pending := drainPiSanitizedText(s)
emit += piControlTokenRE.ReplaceAllString(pending, "")
return emit
}
func drainPiSanitizedText(s string) (string, string) {
var out strings.Builder
for i := 0; i < len(s); {
start, prefixLen := nextPiToolMarkupPrefix(s, i)
if start == -1 {
safeLen := safePiTextEmitLen(s[i:])
out.WriteString(s[i : i+safeLen])
return piControlTokenRE.ReplaceAllString(out.String(), ""), s[i+safeLen:]
}
out.WriteString(s[i:start])
end, ok := scanPiToolMarkupEnd(s, start+prefixLen)
if !ok {
return piControlTokenRE.ReplaceAllString(out.String(), ""), s[start:]
}
i = end
}
return piControlTokenRE.ReplaceAllString(out.String(), ""), ""
}
func stripPiStructuredToolMarkup(s string) string {
var out strings.Builder
for i := 0; i < len(s); {
start, prefixLen := nextPiToolMarkupPrefix(s, i)
if start == -1 {
out.WriteString(s[i:])
break
}
out.WriteString(s[i:start])
end, ok := scanPiToolMarkupEnd(s, start+prefixLen)
if !ok {
out.WriteString(s[start:])
break
}
i = end
}
return out.String()
}
func safePiTextEmitLen(s string) int {
hold := 0
for _, prefix := range []string{"call:", "response:"} {
for n := 1; n < len(prefix) && n <= len(s); n++ {
if strings.HasSuffix(s, prefix[:n]) && n > hold {
hold = n
}
}
}
if i := strings.LastIndexByte(s, '<'); i >= 0 && looksLikePiControlTokenPrefix(s[i:]) {
if len(s)-i > hold {
hold = len(s) - i
}
}
return len(s) - hold
}
func looksLikePiControlTokenPrefix(s string) bool {
if len(s) == 0 || s[0] != '<' || len(s) > 64 {
return false
}
for i := 1; i < len(s); i++ {
b := s[i]
if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' || b == '-' || b == '|' || b == '>' {
continue
}
return false
}
return true
}
func nextPiToolMarkupPrefix(s string, from int) (int, int) {
best := -1
bestLen := 0
for _, prefix := range []string{"call:", "response:"} {
if i := strings.Index(s[from:], prefix); i >= 0 {
abs := from + i
if best == -1 || abs < best {
best = abs
bestLen = len(prefix)
}
}
}
return best, bestLen
}
func scanPiToolMarkupEnd(s string, i int) (int, bool) {
nameStart := i
for i < len(s) && isPiToolNameByte(s[i]) {
i++
}
if i == nameStart || i >= len(s) || s[i] != '{' {
return 0, false
}
const quoteMarker = `<|"|>`
depth := 0
inQuote := false
for i < len(s) {
if strings.HasPrefix(s[i:], quoteMarker) {
inQuote = !inQuote
i += len(quoteMarker)
continue
}
if !inQuote {
switch s[i] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
i++
if strings.HasPrefix(s[i:], "<tool_call|>") {
i += len("<tool_call|>")
}
return i, true
}
}
}
i++
}
return 0, false
}
func isPiToolNameByte(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' || b == '-'
}
func (b *piBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
execName := b.cfg.ExecutablePath
if execName == "" {
execName = "pi"
}
lookedUp, err := exec.LookPath(execName)
if err != nil {
return nil, fmt.Errorf("pi executable not found at %q: %w", execName, err)
}
timeout := opts.Timeout
// Pi's --session flag expects a file path where events are appended.
// The path doubles as our opaque session identifier: we return it as
// SessionID and expect it back as ResumeSessionID on the next turn.
sessionPath := opts.ResumeSessionID
if sessionPath == "" {
p, err := newPiSessionPath()
if err != nil {
return nil, fmt.Errorf("pi session path: %w", err)
}
sessionPath = p
}
if err := ensurePiSessionFile(sessionPath); err != nil {
return nil, fmt.Errorf("pi session file: %w", err)
}
runCtx, cancel := runContext(ctx, timeout)
args := buildPiArgs(prompt, sessionPath, opts, b.cfg.Logger)
argv0, cmdArgs := choosePiInvocation(execName, lookedUp, args, b.cfg.Logger)
cmd := exec.CommandContext(runCtx, argv0, cmdArgs...)
hideAgentWindow(cmd)
b.cfg.Logger.Info("agent command", "exec", argv0, "args", cmdArgs)
cmd.WaitDelay = 10 * time.Second
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
cmd.Env = buildEnv(b.cfg.Env)
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("pi stdout pipe: %w", err)
}
// Attach an explicit stdin pipe so we can close it ourselves. Pi reads
// its prompt from argv (positional, see buildPiArgs) and never expects
// interactive input, but when the parent leaves cmd.Stdin nil and the
// daemon is run under systemd, Pi has been observed to block in its
// event loop awaiting stdin events instead of progressing to "done"
// (#2188). Closing the pipe immediately after Start delivers an
// explicit EOF on a FIFO, which unblocks Pi's readable side.
stdin, err := cmd.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("pi stdin pipe: %w", err)
}
cmd.Stderr = newLogWriter(b.cfg.Logger, "[pi:stderr] ")
if err := cmd.Start(); err != nil {
_ = stdin.Close()
cancel()
return nil, fmt.Errorf("start pi: %w", err)
}
_ = stdin.Close()
b.cfg.Logger.Info("pi started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
// Close stdout when the context is cancelled so scanner.Scan() unblocks.
go func() {
<-runCtx.Done()
_ = stdout.Close()
}()
go func() {
defer cancel()
defer close(msgCh)
defer close(resCh)
startTime := time.Now()
var output strings.Builder
finalStatus := "completed"
var finalError string
usage := make(map[string]TokenUsage)
scanner := bufio.NewScanner(stdout)
// Pi message_update events can be large (they embed the full message
// partial on each delta), so give the scanner generous headroom.
scanner.Buffer(make([]byte, 0, 1024*1024), 32*1024*1024)
var textBuffer strings.Builder
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var evt piStreamEvent
if err := json.Unmarshal([]byte(line), &evt); err != nil {
continue
}
switch evt.Type {
case "agent_start":
trySend(msgCh, Message{Type: MessageStatus, Status: "running"})
case "turn_start":
output.Reset()
textBuffer.Reset()
case "message_update":
if evt.AssistantMessageEvent == nil {
continue
}
switch evt.AssistantMessageEvent.Type {
case "text_delta":
if d := drainPiTextBuffer(&textBuffer, evt.AssistantMessageEvent.Delta); d != "" {
output.WriteString(d)
trySend(msgCh, Message{Type: MessageText, Content: d})
}
case "thinking_delta":
if d := evt.AssistantMessageEvent.Delta; d != "" {
trySend(msgCh, Message{Type: MessageThinking, Content: d})
}
}
case "tool_execution_start":
var params map[string]any
if len(evt.Args) > 0 {
_ = json.Unmarshal(evt.Args, &params)
}
trySend(msgCh, Message{
Type: MessageToolUse,
Tool: evt.ToolName,
CallID: evt.ToolCallID,
Input: params,
})
case "tool_execution_end":
trySend(msgCh, Message{
Type: MessageToolResult,
CallID: evt.ToolCallID,
Output: decodePiResult(evt.Result),
})
case "turn_end":
if msg := decodePiMessage(evt.Message); msg != nil && msg.Usage != nil {
model := msg.Model
if model == "" {
model = opts.Model
}
if model == "" {
model = "unknown"
}
u := usage[model]
u.InputTokens += msg.Usage.Input
u.OutputTokens += msg.Usage.Output
u.CacheReadTokens += msg.Usage.CacheRead
u.CacheWriteTokens += msg.Usage.CacheWrite
usage[model] = u
}
case "error":
errText := decodePiString(evt.Message)
trySend(msgCh, Message{Type: MessageError, Content: errText})
if finalStatus == "completed" {
finalStatus = "failed"
finalError = errText
}
case "auto_retry_end":
if !evt.Success && finalStatus == "completed" {
finalStatus = "failed"
if evt.FinalError != "" {
finalError = evt.FinalError
} else {
finalError = "pi exhausted automatic retries"
}
}
}
}
if d := flushPiTextBuffer(&textBuffer); d != "" {
output.WriteString(d)
trySend(msgCh, Message{Type: MessageText, Content: d})
}
waitErr := cmd.Wait()
duration := time.Since(startTime)
if runCtx.Err() == context.DeadlineExceeded {
finalStatus = "timeout"
finalError = fmt.Sprintf("pi timed out after %s", timeout)
} else if runCtx.Err() == context.Canceled {
finalStatus = "aborted"
finalError = "execution cancelled"
} else if waitErr != nil && finalStatus == "completed" {
finalStatus = "failed"
finalError = fmt.Sprintf("pi exited with error: %v", waitErr)
}
b.cfg.Logger.Info("pi finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
resCh <- Result{
Status: finalStatus,
Output: output.String(),
Error: finalError,
DurationMs: duration.Milliseconds(),
SessionID: sessionPath,
Usage: usage,
}
}()
return &Session{Messages: msgCh, Result: resCh}, nil
}
// ── Pi event types ──
// piStreamEvent is the union of fields we consume from Pi's JSON event
// stream. Fields that can be either string or object across event types
// (e.g. `message`, `result`) are held as json.RawMessage and decoded on
// demand by the switch arms.
type piStreamEvent struct {
Type string `json:"type"`
// message_update
AssistantMessageEvent *piAssistantMessageEvent `json:"assistantMessageEvent,omitempty"`
// tool_execution_start / tool_execution_end
ToolCallID string `json:"toolCallId,omitempty"`
ToolName string `json:"toolName,omitempty"`
Args json.RawMessage `json:"args,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
IsError bool `json:"isError,omitempty"`
// error: Message is a string. turn_end: Message is an object.
Message json.RawMessage `json:"message,omitempty"`
// auto_retry_end
Success bool `json:"success,omitempty"`
FinalError string `json:"finalError,omitempty"`
}
type piAssistantMessageEvent struct {
Type string `json:"type"`
Delta string `json:"delta,omitempty"`
}
type piMessage struct {
Role string `json:"role,omitempty"`
Model string `json:"model,omitempty"`
Usage *piUsage `json:"usage,omitempty"`
}
type piUsage struct {
Input int64 `json:"input"`
Output int64 `json:"output"`
CacheRead int64 `json:"cacheRead"`
CacheWrite int64 `json:"cacheWrite"`
TotalTokens int64 `json:"totalTokens"`
}
func decodePiMessage(raw json.RawMessage) *piMessage {
if len(raw) == 0 {
return nil
}
var m piMessage
if err := json.Unmarshal(raw, &m); err != nil {
return nil
}
return &m
}
func decodePiString(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
return strings.Trim(string(raw), `"`)
}
func decodePiResult(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
return string(raw)
}
// ── Arg builder ──
// piBlockedArgs are flags hardcoded by the daemon that must not be
// overridden by user-configured custom_args. Overriding these would
// break the daemon↔Pi communication protocol.
var piBlockedArgs = map[string]blockedArgMode{
"-p": blockedStandalone, // non-interactive mode
"--print": blockedStandalone, // alias for -p
"--mode": blockedWithValue, // "json" event stream protocol
"--session": blockedWithValue, // daemon manages the session path
}
// buildPiArgs assembles the argv for a one-shot Pi invocation.
//
// Flags:
//
// -p non-interactive mode (prompt is positional)
// --mode json emit one JSON event per line on stdout
// --session <path> session log file (created upfront, reused on resume)
// --provider <name> provider, when Model is "provider/id"
// --model <id> model identifier
//
// Custom args appended before the positional prompt. The prompt is a
// positional argument and must be last.
func buildPiArgs(prompt, sessionPath string, opts ExecOptions, logger *slog.Logger) []string {
args := []string{
"-p",
"--mode", "json",
}
if sessionPath != "" {
args = append(args, "--session", sessionPath)
}
if opts.Model != "" {
provider, model := splitPiModel(opts.Model)
if provider != "" {
args = append(args, "--provider", provider)
}
if model != "" {
args = append(args, "--model", model)
}
}
// Note: we intentionally do NOT pass --tools here. Omitting it lets
// Pi use its full tool registry, including user-installed extension
// tools. Passing --tools acts as a restrictive allowlist that
// silently filters out extension-registered tools (#2379).
// Users who want to restrict tools can do so via custom_args.
//
// SystemPrompt is intentionally not forwarded as --append-system-prompt:
// Pi loads the per-task AGENTS.md the daemon writes into the workdir, so
// inlining the same runtime brief would duplicate it on every turn.
// Verified against Pi 0.67.2 (MUL-5392).
args = append(args, filterCustomArgs(opts.CustomArgs, piBlockedArgs, logger)...)
args = append(args, prompt)
return args
}
// splitPiModel parses a "provider/model" string into its parts. Plain
// "model" strings pass through as (provider="", model="model").
func splitPiModel(s string) (provider, model string) {
s = strings.TrimSpace(s)
if i := strings.Index(s, "/"); i >= 0 {
return strings.TrimSpace(s[:i]), strings.TrimSpace(s[i+1:])
}
return "", s
}
// ── Session path ──
// piSessionDir returns the directory where Pi session JSONL files live.
// Exported via a helper so the usage scanner (package usage) can point at
// the same location without duplicating the path construction.
func piSessionDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".multica", "pi-sessions"), nil
}
func newPiSessionPath() (string, error) {
dir, err := piSessionDir()
if err != nil {
return "", err
}
name := fmt.Sprintf("%s.jsonl", time.Now().UTC().Format("20060102T150405.000000000"))
return filepath.Join(dir, name), nil
}
// ensurePiSessionFile creates an empty session file if one does not yet
// exist at path. Pi refuses to start when --session points at a missing
// file; paths that already exist (a resumed session) are left untouched.
func ensurePiSessionFile(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0o644)
if err != nil {
return err
}
return f.Close()
}
// PiSessionDir exposes piSessionDir to other packages in this module.
func PiSessionDir() (string, error) {
return piSessionDir()
}