mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-11-14 00:07:10 +01:00
314 lines
8.5 KiB
Go
314 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
|
|
}
|
|
for _, path := range workerCandidates() {
|
|
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")
|
|
}
|
|
|
|
func workerCandidates() []string {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
}
|