feat(runtime): extract Bootstrap/RuntimeHandle/RunPrompt into pkg/runtime
Introduces a reusable runtime package that encapsulates the full lifecycle of a picoclaw agent session: provider creation, model wiring, message bus setup, outbound drain, and agent loop initialization. pkg/runtime/bootstrap.go - Bootstrap() creates a RuntimeHandle from a Config and BootstrapOptions - BootstrapOptions.WrapModel allows callers (e.g. eval-runner) to inject an instrumented model wrapper without coupling to agent internals - OutboundMode enum (None/Consume/Drop/Callback) controls how outbound messages are drained so producers never block - RuntimeHandle.Close() tears down agentLoop, cancels context, and waits for the outbound goroutine to exit pkg/runtime/config.go - ResolveBaseConfigPath() prefers XDG (~/.config/picoclaw/config.json) then falls back to legacy (~/.picoclaw/config.json) - LoadResolvedConfig() loads base config then applies an optional overlay - LoadEvalConfig() convenience wrapper for eval-runner (reads PICOCLAW_EVAL_CONFIG env var as overlay path) - EnsureMinProviderTimeout() sets a floor on all provider timeout fields pkg/runtime/execute.go - RunPrompt() runs a single prompt through an existing RuntimeHandle and returns a RunResult with output, error, duration, and session key - NewSessionKey() generates deterministic session keys from a prefix + time pkg/runtime/runtime_test.go - Tests for XDG vs legacy path resolution - Tests for overlay config merging and base value preservation - Tests for provider timeout floor enforcement - Tests for outbound drain modes (drop/consume/callback)
This commit is contained in:
parent
61e9c34165
commit
938b77f9b5
4 changed files with 434 additions and 0 deletions
143
pkg/runtime/bootstrap.go
Normal file
143
pkg/runtime/bootstrap.go
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
fantasy "charm.land/fantasy"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/agent"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
picofantasy "github.com/sipeed/picoclaw/pkg/fantasy"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OutboundMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OutboundModeNone OutboundMode = "none"
|
||||||
|
OutboundModeConsume OutboundMode = "consume"
|
||||||
|
OutboundModeDrop OutboundMode = "drop"
|
||||||
|
OutboundModeCallback OutboundMode = "callback"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BootstrapOptions struct {
|
||||||
|
Timeout time.Duration
|
||||||
|
OutboundMode OutboundMode
|
||||||
|
OutboundCallback func(bus.OutboundMessage)
|
||||||
|
WrapModel func(fantasy.LanguageModel) fantasy.LanguageModel
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeHandle struct {
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
agentLoop *agent.AgentLoop
|
||||||
|
msgBus *bus.MessageBus
|
||||||
|
outDone chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RuntimeHandle) Context() context.Context {
|
||||||
|
return h.ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RuntimeHandle) AgentLoop() *agent.AgentLoop {
|
||||||
|
return h.agentLoop
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RuntimeHandle) MessageBus() *bus.MessageBus {
|
||||||
|
return h.msgBus
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RuntimeHandle) Close() {
|
||||||
|
if h.agentLoop != nil {
|
||||||
|
h.agentLoop.Stop()
|
||||||
|
}
|
||||||
|
if h.cancel != nil {
|
||||||
|
h.cancel()
|
||||||
|
}
|
||||||
|
if h.outDone != nil {
|
||||||
|
<-h.outDone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Bootstrap(parent context.Context, cfg *config.Config, opts BootstrapOptions) (*RuntimeHandle, error) {
|
||||||
|
if parent == nil {
|
||||||
|
parent = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := withExecutionContext(parent, opts.Timeout)
|
||||||
|
|
||||||
|
provider, err := picofantasy.CreateProvider(cfg)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
return nil, fmt.Errorf("provider error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := provider.LanguageModel(ctx, picofantasy.ModelID(cfg))
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
return nil, fmt.Errorf("model error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.WrapModel != nil {
|
||||||
|
model = opts.WrapModel(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
outDone := startOutbound(msgBus, ctx, opts)
|
||||||
|
|
||||||
|
agentLoop, err := agent.NewAgentLoop(ctx, cfg, msgBus, model)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
if outDone != nil {
|
||||||
|
<-outDone
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("agent loop init error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RuntimeHandle{
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
agentLoop: agentLoop,
|
||||||
|
msgBus: msgBus,
|
||||||
|
outDone: outDone,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func withExecutionContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||||
|
if timeout > 0 {
|
||||||
|
return context.WithTimeout(parent, timeout)
|
||||||
|
}
|
||||||
|
return context.WithCancel(parent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startOutbound(msgBus *bus.MessageBus, ctx context.Context, opts BootstrapOptions) chan struct{} {
|
||||||
|
mode := opts.OutboundMode
|
||||||
|
if mode == "" || mode == OutboundModeNone {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
for {
|
||||||
|
msg, ok := msgBus.SubscribeOutbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case OutboundModeConsume, OutboundModeDrop:
|
||||||
|
// Intentionally no-op: consume and discard so producers never block.
|
||||||
|
case OutboundModeCallback:
|
||||||
|
if opts.OutboundCallback != nil {
|
||||||
|
opts.OutboundCallback(msg)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Unknown modes degrade safely to consume-and-discard.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return done
|
||||||
|
}
|
||||||
102
pkg/runtime/config.go
Normal file
102
pkg/runtime/config.go
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const EvalConfigEnvVar = "PICOCLAW_EVAL_CONFIG"
|
||||||
|
|
||||||
|
type LoadConfigOptions struct {
|
||||||
|
BaseConfigPath string
|
||||||
|
OverlayConfigPath string
|
||||||
|
MinProviderTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveBaseConfigPath() string {
|
||||||
|
// Prefer XDG standard path (~/.config/picoclaw/config.json) when present.
|
||||||
|
if xdgPath, err := config.DefaultConfigPath(); err == nil {
|
||||||
|
if _, statErr := os.Stat(xdgPath); statErr == nil {
|
||||||
|
return xdgPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
legacy := filepath.Join(home, ".picoclaw", "config.json")
|
||||||
|
if _, err := os.Stat(legacy); err == nil {
|
||||||
|
return legacy
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither exists; return XDG path if resolvable so defaults still load.
|
||||||
|
if xdgPath, err := config.DefaultConfigPath(); err == nil {
|
||||||
|
return xdgPath
|
||||||
|
}
|
||||||
|
return legacy
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadResolvedConfig(opts LoadConfigOptions) (*config.Config, error) {
|
||||||
|
basePath := opts.BaseConfigPath
|
||||||
|
if basePath == "" {
|
||||||
|
basePath = ResolveBaseConfigPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(basePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load base config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.OverlayConfigPath != "" {
|
||||||
|
if err := config.OverlayConfigFile(cfg, opts.OverlayConfigPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("load config overlay: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EnsureMinProviderTimeout(cfg, opts.MinProviderTimeout)
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadEvalConfig(minTimeout time.Duration) (*config.Config, error) {
|
||||||
|
return LoadResolvedConfig(LoadConfigOptions{
|
||||||
|
OverlayConfigPath: os.Getenv(EvalConfigEnvVar),
|
||||||
|
MinProviderTimeout: minTimeout,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnsureMinProviderTimeout(cfg *config.Config, minTimeout time.Duration) {
|
||||||
|
if cfg == nil || minTimeout <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
minSeconds := int(minTimeout.Seconds())
|
||||||
|
if minSeconds <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
set := func(p *config.ProviderConfig) {
|
||||||
|
if p.Timeout == 0 || p.Timeout < minSeconds {
|
||||||
|
p.Timeout = minSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
providers := []*config.ProviderConfig{
|
||||||
|
&cfg.Providers.Anthropic,
|
||||||
|
&cfg.Providers.OpenAI.ProviderConfig,
|
||||||
|
&cfg.Providers.OpenRouter,
|
||||||
|
&cfg.Providers.Groq,
|
||||||
|
&cfg.Providers.Zhipu,
|
||||||
|
&cfg.Providers.VLLM,
|
||||||
|
&cfg.Providers.Gemini,
|
||||||
|
&cfg.Providers.Nvidia,
|
||||||
|
&cfg.Providers.Ollama,
|
||||||
|
&cfg.Providers.Moonshot,
|
||||||
|
&cfg.Providers.ShengSuanYun,
|
||||||
|
&cfg.Providers.DeepSeek,
|
||||||
|
&cfg.Providers.GitHubCopilot,
|
||||||
|
}
|
||||||
|
for _, p := range providers {
|
||||||
|
set(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
45
pkg/runtime/execute.go
Normal file
45
pkg/runtime/execute.go
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RunResult struct {
|
||||||
|
Output string
|
||||||
|
Error string
|
||||||
|
SessionKey string
|
||||||
|
Duration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunPrompt(ctx context.Context, handle *RuntimeHandle, prompt, sessionKey string) RunResult {
|
||||||
|
start := time.Now()
|
||||||
|
result := RunResult{SessionKey: sessionKey}
|
||||||
|
|
||||||
|
if handle == nil || handle.AgentLoop() == nil {
|
||||||
|
result.Error = "runtime handle is not initialized"
|
||||||
|
result.Duration = time.Since(start)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
runCtx := ctx
|
||||||
|
if runCtx == nil {
|
||||||
|
runCtx = handle.Context()
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := handle.AgentLoop().ProcessDirect(runCtx, prompt, sessionKey)
|
||||||
|
result.Output = output
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
}
|
||||||
|
result.Duration = time.Since(start)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSessionKey(prefix string, now time.Time) string {
|
||||||
|
if prefix == "" {
|
||||||
|
prefix = "session"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s:%d", prefix, now.UnixNano())
|
||||||
|
}
|
||||||
144
pkg/runtime/runtime_test.go
Normal file
144
pkg/runtime/runtime_test.go
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveBaseConfigPath_PrefersXDGOverLegacy(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
xdg := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", xdg)
|
||||||
|
|
||||||
|
xdgPath := filepath.Join(xdg, "picoclaw", "config.json")
|
||||||
|
legacyPath := filepath.Join(home, ".picoclaw", "config.json")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(xdgPath), 0o755))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(legacyPath), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(xdgPath, []byte(`{}`), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(legacyPath, []byte(`{}`), 0o644))
|
||||||
|
|
||||||
|
got := ResolveBaseConfigPath()
|
||||||
|
assert.Equal(t, xdgPath, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveBaseConfigPath_FallsBackToLegacyWhenXDGMissing(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
xdg := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", xdg)
|
||||||
|
|
||||||
|
legacyPath := filepath.Join(home, ".picoclaw", "config.json")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(legacyPath), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(legacyPath, []byte(`{}`), 0o644))
|
||||||
|
|
||||||
|
got := ResolveBaseConfigPath()
|
||||||
|
assert.Equal(t, legacyPath, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadResolvedConfig_AppliesOverlayAndKeepsBaseValues(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
basePath := filepath.Join(dir, "base.json")
|
||||||
|
overlayPath := filepath.Join(dir, "overlay.json")
|
||||||
|
|
||||||
|
base := []byte(`{
|
||||||
|
"providers": {"openai": {"api_key": "base-key"}},
|
||||||
|
"agents": {"defaults": {"restrict_to_sandbox": false}}
|
||||||
|
}`)
|
||||||
|
overlay := []byte(`{
|
||||||
|
"agents": {"defaults": {"restrict_to_sandbox": true}}
|
||||||
|
}`)
|
||||||
|
require.NoError(t, os.WriteFile(basePath, base, 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(overlayPath, overlay, 0o644))
|
||||||
|
|
||||||
|
cfg, err := LoadResolvedConfig(LoadConfigOptions{
|
||||||
|
BaseConfigPath: basePath,
|
||||||
|
OverlayConfigPath: overlayPath,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "base-key", cfg.Providers.OpenAI.APIKey)
|
||||||
|
assert.True(t, cfg.Agents.Defaults.RestrictToSandbox)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureMinProviderTimeout_SetsFloor(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
basePath := filepath.Join(dir, "base.json")
|
||||||
|
require.NoError(t, os.WriteFile(basePath, []byte(`{"providers":{"openai":{"timeout":0}}}`), 0o644))
|
||||||
|
|
||||||
|
cfg, err := LoadResolvedConfig(LoadConfigOptions{
|
||||||
|
BaseConfigPath: basePath,
|
||||||
|
MinProviderTimeout: 180 * time.Second,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 180, cfg.Providers.OpenAI.Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartOutbound_DropAndConsumeDoNotBlockPublishers(t *testing.T) {
|
||||||
|
modes := []OutboundMode{OutboundModeDrop, OutboundModeConsume}
|
||||||
|
for _, mode := range modes {
|
||||||
|
t.Run(string(mode), func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
done := startOutbound(msgBus, ctx, BootstrapOptions{OutboundMode: mode})
|
||||||
|
require.NotNil(t, done)
|
||||||
|
|
||||||
|
publishDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(publishDone)
|
||||||
|
for i := 0; i < 300; i++ {
|
||||||
|
msgBus.PublishOutbound(bus.OutboundMessage{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "test",
|
||||||
|
Content: "payload",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-publishDone:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatalf("publishing outbound messages blocked under mode=%s", mode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartOutbound_CallbackReceivesMessages(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
received := make(chan bus.OutboundMessage, 1)
|
||||||
|
done := startOutbound(msgBus, ctx, BootstrapOptions{
|
||||||
|
OutboundMode: OutboundModeCallback,
|
||||||
|
OutboundCallback: func(msg bus.OutboundMessage) {
|
||||||
|
select {
|
||||||
|
case received <- msg:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NotNil(t, done)
|
||||||
|
|
||||||
|
msgBus.PublishOutbound(bus.OutboundMessage{
|
||||||
|
Channel: "cli",
|
||||||
|
ChatID: "direct",
|
||||||
|
Content: "hello",
|
||||||
|
})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-received:
|
||||||
|
assert.Equal(t, "hello", got.Content)
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("did not receive callback outbound message")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue