Files
CyberStrikeAI/internal/agentruntime/pi.go
T
Sycun 35508eb614 feat(pi): add the Pi agent runtime with native workflow and retrieval engines
Introduce an isolated Node worker that hosts the Pi SDK while Go keeps
business state, permissions, MCP execution, evidence and delivery checks.
The worker is published per source fingerprint, streams events back to Go
and supports tool callbacks, cancellation, session resume and per-model
transport settings.

Agent dispatch, tool search, plan/task tools and migration controls are
wired on top of it: large tool catalogs load through Pi tool_search, the
conversation task board keeps its existing tools, and legacy mode/route
values still resolve. Workflow gains a native DAG with versioned JSON
checkpoints, and knowledge retrieval moves to native documents, splitter,
HTTP embeddings, vector search and reranking.
2026-11-12 17:55:33 +08:00

310 lines
8.5 KiB
Go

package agentruntime
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
)
const maxRecordBytes = 32 * 1024 * 1024
type Pi struct {
NodePath string
WorkerPath string
}
type runRequest struct {
Request
LegacyProfile json.RawMessage `json:"profile"`
}
type record struct {
Type string `json:"type"`
Version int `json:"version,omitempty"`
Request *runRequest `json:"request,omitempty"`
Event *Event `json:"event,omitempty"`
Call *ToolCall `json:"call,omitempty"`
Result *Result `json:"result,omitempty"`
Error string `json:"error,omitempty"`
SDKVersion string `json:"sdkVersion,omitempty"`
}
// ResolveWorker supports source installs and release bundles without relying on PATH for scripts.
func ResolveWorker(configured string) (string, error) {
if configured != "" {
path, err := filepath.Abs(configured)
if err != nil {
return "", err
}
if st, err := os.Stat(path); err != nil || !st.Mode().IsRegular() {
return "", fmt.Errorf("Pi worker does not exist: %s", path)
}
return path, nil
}
var candidates []string
if exe, err := os.Executable(); err == nil {
candidates = append(candidates, filepath.Join(filepath.Dir(exe), "runtime/pi/dist/worker.js"))
}
if cwd, err := os.Getwd(); err == nil {
candidates = append(candidates, filepath.Join(cwd, "runtime/pi/dist/worker.js"))
// Package tests and developer commands may run below the repository root.
for dir := cwd; dir != filepath.Dir(dir); dir = filepath.Dir(dir) {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
candidates = append(candidates, filepath.Join(dir, "runtime/pi/dist/worker.js"))
break
}
}
}
for _, path := range candidates {
root := filepath.Dir(filepath.Dir(path))
if fingerprint, err := SourceFingerprint(root); err == nil {
bundle := filepath.Join(root, ".builds", fingerprint)
if _, err = os.Stat(filepath.Join(bundle, ".ready")); err == nil {
if _, err = os.Stat(filepath.Join(bundle, "dist", "worker.js")); err == nil {
return filepath.Join(bundle, "dist", "worker.js"), nil
}
}
continue
}
if st, err := os.Stat(path); err == nil && st.Mode().IsRegular() {
return path, nil
}
}
return "", errors.New("Pi worker missing; run bash scripts/build-pi-runtime.sh, or configure agent_runtime.worker_path")
}
// Run owns one isolated worker and reaps it on every exit. Tool execution stays in the host.
func (p Pi) Run(ctx context.Context, req Request, execute ToolExecutor, emit func(Event), bind func(Control) func()) (*Result, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if req.WorkingDir == "" {
var err error
req.WorkingDir, err = os.Getwd()
if err != nil {
return nil, err
}
}
release, err := acquireSession(ctx, req.SessionDir)
if err != nil {
return nil, err
}
defer release()
worker, err := ResolveWorker(p.WorkerPath)
if err != nil {
return nil, err
}
node := p.NodePath
if node == "" {
node = "node"
}
scratchParent, err := os.MkdirTemp("", "cyberstrike-pi-host-")
if err != nil {
return nil, err
}
defer os.RemoveAll(scratchParent)
cmd := exec.Command(node, worker)
cmd.Env = append(os.Environ(), "CYBERSTRIKE_PI_TMP_PARENT="+scratchParent)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
stdin.Close()
return nil, err
}
// Worker protocol carries failures. Never forward arbitrary stderr (which may include provider secrets).
cmd.Stderr = io.Discard
if err = cmd.Start(); err != nil {
stdin.Close()
stdout.Close()
return nil, fmt.Errorf("start Pi worker: %w", err)
}
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
ioCtx, stopIO := context.WithCancel(context.Background())
defer stopIO()
var writeMu sync.Mutex
write := func(v any) error {
body, err := json.Marshal(v)
if err != nil {
return err
}
if len(body) > maxRecordBytes {
return errors.New("Pi request exceeds protocol record limit")
}
writeMu.Lock()
defer writeMu.Unlock()
_, err = stdin.Write(append(body, '\n'))
return err
}
incoming := make(chan record, 32)
readDone := make(chan error, 1)
go func() {
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 64*1024), maxRecordBytes+1)
for scanner.Scan() {
var r record
if err := json.Unmarshal(scanner.Bytes(), &r); err != nil {
readDone <- fmt.Errorf("invalid Pi protocol JSON: %w", err)
return
}
select {
case incoming <- r:
case <-ioCtx.Done():
readDone <- ioCtx.Err()
return
}
}
readDone <- scanner.Err()
}()
defer func() {
cancel()
stopIO()
stdin.Close()
_ = cmd.Process.Kill()
_ = cmd.Wait()
stdout.Close()
}()
readyTimeout := time.NewTimer(20 * time.Second)
defer readyTimeout.Stop()
select {
case r := <-incoming:
if r.Type != "ready" || r.Version != ProtocolVersion || r.SDKVersion != "1.5.2" {
return nil, errors.New("Pi runtime protocol mismatch")
}
case err := <-readDone:
return nil, fmt.Errorf("Pi worker exited before handshake: %v", err)
case <-readyTimeout.C:
return nil, errors.New("Pi worker startup timeout")
case <-ctx.Done():
return nil, ctx.Err()
}
// Older protocol v1 workers require a profile object. Keep it disabled.
run := runRequest{Request: req, LegacyProfile: json.RawMessage(`{"enabled":false}`)}
if err = write(record{Type: "run", Version: ProtocolVersion, Request: &run}); err != nil {
return nil, err
}
if bind != nil {
unbind := bind(Control{
Steer: func(s string) error { return write(map[string]any{"type": "steer", "message": s}) },
Cancel: func() error { cancel(); return write(map[string]any{"type": "cancel"}) },
})
if unbind != nil {
defer unbind()
}
}
type executed struct {
call string
result ToolResult
done chan struct{}
}
var toolMu sync.Mutex
toolCache := map[string]*executed{}
toolSlots := make(chan struct{}, 32)
ctxDone := ctx.Done()
var cancelDeadline <-chan time.Time
var cancelTimer *time.Timer
defer func() {
if cancelTimer != nil {
cancelTimer.Stop()
}
}()
for {
select {
case r := <-incoming:
switch r.Type {
case "event":
if emit != nil && r.Event != nil {
emit(*r.Event)
}
case "tool_call":
if r.Call == nil || r.Call.ID == "" {
return nil, errors.New("Pi tool call has no identity")
}
call := *r.Call
select {
case toolSlots <- struct{}{}:
default:
return nil, errors.New("Pi tool concurrency limit exceeded")
}
go func() {
defer func() { <-toolSlots }()
body, _ := json.Marshal(call)
toolMu.Lock()
cached := toolCache[call.ID]
if cached == nil {
cached = &executed{call: string(body), done: make(chan struct{})}
toolCache[call.ID] = cached
toolMu.Unlock()
res := ToolResult{IsError: true, Content: "No host tool executor"}
if execute != nil {
var invokeErr error
res, invokeErr = execute(runCtx, call)
if invokeErr != nil {
res.IsError = true
res.Content = invokeErr.Error()
}
}
cached.result = res
close(cached.done)
} else {
toolMu.Unlock()
}
select {
case <-cached.done:
case <-runCtx.Done():
return
}
res := cached.result
if cached.call != string(body) {
res = ToolResult{IsError: true, Content: "Tool call ID reused with different arguments"}
}
_ = write(map[string]any{"type": "tool_result", "id": call.ID, "result": res})
}()
case "result":
if r.Result == nil {
return nil, errors.New("Pi returned an empty result")
}
if ctx.Err() != nil {
return r.Result, ctx.Err()
}
if r.Result.Error != "" {
return r.Result, fmt.Errorf("Pi runtime error: %s", r.Result.Error)
}
if r.Result.Status == "canceled" {
return r.Result, context.Canceled
}
return r.Result, nil
case "fatal":
return nil, fmt.Errorf("Pi protocol failure: %s", r.Error)
default:
return nil, fmt.Errorf("unexpected Pi record: %s", r.Type)
}
case err := <-readDone:
// All queued protocol records precede EOF; process them before interpreting exit.
if len(incoming) > 0 {
readDone <- err
continue
}
return nil, fmt.Errorf("Pi worker exited without a result: %v", err)
case <-ctxDone:
ctxDone = nil
_ = write(map[string]any{"type": "cancel"})
cancelTimer = time.NewTimer(2 * time.Second)
cancelDeadline = cancelTimer.C
case <-cancelDeadline:
return nil, ctx.Err()
}
}
}