refactor(pkg): adapt agent, runtime, migrate, fantasy for SDK/CLI structure

Supporting changes for the extracted CLI and dragonscale SDK:
- pkg/agent: agent_run, loop, toolloop adjustments for service wiring
- pkg/runtime: bootstrap, config updates for new invocation paths
- pkg/migrate: migrate logic updates
- pkg/fantasy: adapter changes for tool wiring
- pkg/channels: manager tweak
- pkg/heartbeat: service adjustment
- pkg/messages: types update
- pkg/sync: identity_test update

Remove t.Parallel from TestLoadResolvedConfig_FallsBackWhenExplicitBaseConfigInvalid
(t.Setenv incompatible with t.Parallel).
This commit is contained in:
ZanzyTHEbar 2026-02-22 15:31:40 +00:00
parent 3286555321
commit a76db05a09
15 changed files with 283 additions and 130 deletions

View file

@ -13,7 +13,7 @@ import (
"github.com/ZanzyTHEbar/dragonscale/pkg"
"github.com/ZanzyTHEbar/dragonscale/pkg/bus"
"github.com/ZanzyTHEbar/dragonscale/pkg/constants"
picofantasy "github.com/ZanzyTHEbar/dragonscale/pkg/fantasy"
dragonfantasy "github.com/ZanzyTHEbar/dragonscale/pkg/fantasy"
"github.com/ZanzyTHEbar/dragonscale/pkg/ids"
"github.com/ZanzyTHEbar/dragonscale/pkg/logger"
"github.com/ZanzyTHEbar/dragonscale/pkg/memory"
@ -93,7 +93,7 @@ func (al *AgentLoop) assembleContext(ctx context.Context, opts processOptions) (
"history": formatMessagesForLog(historyMsgs),
})
fantasyHistory := picofantasy.MessagesToFantasy(historyMsgs)
fantasyHistory := dragonfantasy.MessagesToFantasy(historyMsgs)
adaptedTools, prepareStep := al.prepareToolset(ctx, opts)
agent, err := al.createFantasyAgent(ctx, opts, systemPrompt, adaptedTools, prepareStep)
if err != nil {
@ -177,13 +177,13 @@ func (al *AgentLoop) splitMessages(opts processOptions, builtMsgs []messages.Mes
}
func (al *AgentLoop) prepareToolset(ctx context.Context, opts processOptions) ([]fantasy.AgentTool, func(context.Context, fantasy.PrepareStepFunctionOptions) (context.Context, fantasy.PrepareStepResult, error)) {
adaptCfg := picofantasy.AdaptedToolsConfig{
adaptCfg := dragonfantasy.AdaptedToolsConfig{
MemStore: al.memoryStore,
AgentID: pkg.NAME,
SessionKey: opts.SessionKey,
}
adaptedTools := picofantasy.BuildAdaptedTools(al.tools, al.bus, opts.Channel, opts.ChatID, adaptCfg)
adaptedTools := dragonfantasy.BuildAdaptedTools(al.tools, al.bus, opts.Channel, opts.ChatID, adaptCfg)
if al.toolResultSearch != nil {
adaptedTools = append(adaptedTools, al.toolResultSearch)
}
@ -217,7 +217,7 @@ func (al *AgentLoop) prepareToolset(ctx context.Context, opts processOptions) ([
return ctx, fantasy.PrepareStepResult{}, nil
}
newAdapted := picofantasy.AdaptTools(newTools, msgBus, channel, chatID, adaptCfg)
newAdapted := dragonfantasy.AdaptTools(newTools, msgBus, channel, chatID, adaptCfg)
expanded := append(adaptedTools, newAdapted...)
adaptedTools = expanded
@ -419,7 +419,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
}
for _, step := range result.Steps {
stepMsgs := picofantasy.StepToMessages(step)
stepMsgs := dragonfantasy.StepToMessages(step)
for _, m := range stepMsgs {
al.sessions.AddFullMessage(opts.SessionKey, m)
}
@ -458,7 +458,7 @@ func (al *AgentLoop) runStreaming(ctx context.Context, opts processOptions, ac a
},
OnStepFinish: func(step fantasy.StepResult) error {
stepMsgs := picofantasy.StepToMessages(step)
stepMsgs := dragonfantasy.StepToMessages(step)
for _, m := range stepMsgs {
al.sessions.AddFullMessage(opts.SessionKey, m)
}

View file

@ -1,5 +1,5 @@
// DragonScale - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// Inspired by and based on picoclaw: https://github.com/sipeed/picoclaw
// License: MIT
//
// Copyright (c) 2026 DragonScale contributors
@ -32,7 +32,7 @@ import (
"github.com/ZanzyTHEbar/dragonscale/pkg/security/securebus"
"github.com/ZanzyTHEbar/dragonscale/pkg/session"
"github.com/ZanzyTHEbar/dragonscale/pkg/state"
picosync "github.com/ZanzyTHEbar/dragonscale/pkg/sync"
dragonsync "github.com/ZanzyTHEbar/dragonscale/pkg/sync"
"github.com/ZanzyTHEbar/dragonscale/pkg/tools"
)
@ -47,21 +47,21 @@ type AgentLoop struct {
state *state.Manager
contextBuilder *ContextBuilder
tools *tools.ToolRegistry
memoryStore *memstore.MemoryStore // 3-tier MemGPT memory (always initialized)
memDelegate memory.MemoryDelegate // DB delegate (always initialized)
obsManager *observation.Manager // Observational memory (always initialized)
secureBus *securebus.Bus // ITR SecureBus (always initialized)
queries *memsqlc.Queries // SQL query surface for runtime persistence
kvDelegate KVDelegate // KV adapter for offloaded tool results
stateStore *StateStore // Agent run state persistence
conversationIDs sync.Map // Owner: agent_run.go — wrote by prepareRuntimeState, read in prepareRuntimeState/load path
conversationMu sync.Mutex // serializes conversation creation path
identitySync *picosync.IdentitySync // File→DB sync for identity docs (nil if memory disabled)
activeSessionKey atomic.Value // Owner: agent_run.go — written in runAgentLoop, read by router/toolloop for context routing
running atomic.Bool // Owner: loop.go — lifecycle gate controlled by Run/Stop only
summarizing sync.Map // Owner: summarizer.go — intended for async summarization lockout, currently gated by TODO path
summarizeFailures sync.Map // Owner: summarizer.go — write/read in forceCompression + summarizeSession error paths
cfg *config.Config // Stored for subagent factory access
memoryStore *memstore.MemoryStore // 3-tier MemGPT memory (always initialized)
memDelegate memory.MemoryDelegate // DB delegate (always initialized)
obsManager *observation.Manager // Observational memory (always initialized)
secureBus *securebus.Bus // ITR SecureBus (always initialized)
queries *memsqlc.Queries // SQL query surface for runtime persistence
kvDelegate KVDelegate // KV adapter for offloaded tool results
stateStore *StateStore // Agent run state persistence
conversationIDs sync.Map // Owner: agent_run.go — wrote by prepareRuntimeState, read in prepareRuntimeState/load path
conversationMu sync.Mutex // serializes conversation creation path
identitySync *dragonsync.IdentitySync // File→DB sync for identity docs (nil if memory disabled)
activeSessionKey atomic.Value // Owner: agent_run.go — written in runAgentLoop, read by router/toolloop for context routing
running atomic.Bool // Owner: loop.go — lifecycle gate controlled by Run/Stop only
summarizing sync.Map // Owner: summarizer.go — intended for async summarization lockout, currently gated by TODO path
summarizeFailures sync.Map // Owner: summarizer.go — write/read in forceCompression + summarizeSession error paths
cfg *config.Config // Stored for subagent factory access
channelManager *channels.Manager
commandRegistry []SlashCommand
outputOverride atomic.Value // Owner: command_handler.go — CLI output redirection target for internal messages
@ -217,13 +217,13 @@ func NewAgentLoop(ctx context.Context, cfg *config.Config, msgBus *bus.MessageBu
}
// Identity file sync (disk → DB)
var idSync *picosync.IdentitySync
var idSync *dragonsync.IdentitySync
identityDir, idErr := config.IdentityDir()
if idErr != nil {
logger.WarnCF("agent", "Could not resolve identity dir, identity sync disabled",
map[string]interface{}{"error": idErr.Error()})
} else {
idSync = picosync.New(identityDir, pkg.NAME, memDelegate)
idSync = dragonsync.New(identityDir, pkg.NAME, memDelegate)
if syncErr := idSync.SyncAll(ctx); syncErr != nil {
logger.WarnCF("agent", "Initial identity sync failed (non-fatal)",
map[string]interface{}{"error": syncErr.Error()})

View file

@ -12,7 +12,7 @@ import (
fantasy "charm.land/fantasy"
"github.com/ZanzyTHEbar/dragonscale/pkg"
picofantasy "github.com/ZanzyTHEbar/dragonscale/pkg/fantasy"
dragonfantasy "github.com/ZanzyTHEbar/dragonscale/pkg/fantasy"
"github.com/ZanzyTHEbar/dragonscale/pkg/logger"
memstore "github.com/ZanzyTHEbar/dragonscale/pkg/memory/store"
"github.com/ZanzyTHEbar/dragonscale/pkg/tools"
@ -90,12 +90,12 @@ func runToolLoopWithRuntime(
return nil, fmt.Errorf("tool runtime is required")
}
adaptCfg := picofantasy.AdaptedToolsConfig{
adaptCfg := dragonfantasy.AdaptedToolsConfig{
MemStore: ms,
AgentID: pkg.NAME,
SessionKey: sessionKey,
}
adaptedTools := picofantasy.BuildAdaptedTools(config.Tools, config.Bus, channel, chatID, adaptCfg)
adaptedTools := dragonfantasy.BuildAdaptedTools(config.Tools, config.Bus, channel, chatID, adaptCfg)
if len(extraTools) > 0 {
adaptedTools = append(adaptedTools, extraTools...)
}
@ -126,7 +126,7 @@ func runToolLoopWithRuntime(
if len(newTools) == 0 {
return ctx, fantasy.PrepareStepResult{}, nil
}
newAdapted := picofantasy.AdaptTools(newTools, config.Bus, channel, chatID, adaptCfg)
newAdapted := dragonfantasy.AdaptTools(newTools, config.Bus, channel, chatID, adaptCfg)
adaptedTools = append(adaptedTools, newAdapted...)
return ctx, fantasy.PrepareStepResult{Tools: adaptedTools}, nil
}

View file

@ -1,5 +1,5 @@
// DragonScale - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// Inspired by and based on picoclaw: https://github.com/sipeed/picoclaw
// License: MIT
//
// Copyright (c) 2026 DragonScale contributors

View file

@ -18,11 +18,11 @@ import (
"github.com/ZanzyTHEbar/dragonscale/pkg/tools"
)
// PicoToolAdapter wraps a DragonScale tool as a Fantasy AgentTool.
// DragonToolAdapter wraps a DragonScale tool as a Fantasy AgentTool.
// It bridges DragonScale's dual-channel ToolResult semantics with Fantasy's
// simple ToolResponse by publishing ForUser content to the bus as a side effect
// and returning only ForLLM content to Fantasy.
type PicoToolAdapter struct {
type DragonToolAdapter struct {
inner tools.Tool
bus *bus.MessageBus
channel string
@ -32,8 +32,8 @@ type PicoToolAdapter struct {
sessionKey string
}
// Compile-time check that PicoToolAdapter implements fantasy.AgentTool.
var _ fantasy.AgentTool = (*PicoToolAdapter)(nil)
// Compile-time check that DragonToolAdapter implements fantasy.AgentTool.
var _ fantasy.AgentTool = (*DragonToolAdapter)(nil)
// Info returns Fantasy-compatible tool metadata from the DragonScale tool.
// Fantasy's ToolInfo expects Parameters to be just the properties map and
@ -41,7 +41,7 @@ var _ fantasy.AgentTool = (*PicoToolAdapter)(nil)
// Schema object from Parameters() (with "type", "properties", "required"
// keys), so we must unwrap it here to avoid double-wrapping in
// agent.prepareTools() and agent.validateToolCall().
func (a *PicoToolAdapter) Info() fantasy.ToolInfo {
func (a *DragonToolAdapter) Info() fantasy.ToolInfo {
params := a.inner.Parameters()
properties, required := unwrapSchema(params)
return fantasy.ToolInfo{
@ -83,7 +83,7 @@ func unwrapSchema(params map[string]interface{}) (map[string]interface{}, []stri
// - If the tool result has ForUser content and is not Silent, publishes to the bus.
// - If the tool is a ContextualTool, sets channel/chatID context before execution.
// - If the tool is an AsyncTool, wires a callback that publishes results to the bus.
func (a *PicoToolAdapter) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
func (a *DragonToolAdapter) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
// 1. Deserialize Fantasy's JSON string input into DragonScale's map format.
args, err := parseToolArgs(call.Input)
if err != nil {
@ -147,12 +147,12 @@ func (a *PicoToolAdapter) Run(ctx context.Context, call fantasy.ToolCall) (fanta
}
// ProviderOptions returns nil — DragonScale tools have no provider-specific options.
func (a *PicoToolAdapter) ProviderOptions() fantasy.ProviderOptions {
func (a *DragonToolAdapter) ProviderOptions() fantasy.ProviderOptions {
return fantasy.ProviderOptions{}
}
// SetProviderOptions is a no-op for DragonScale tools.
func (a *PicoToolAdapter) SetProviderOptions(_ fantasy.ProviderOptions) {}
func (a *DragonToolAdapter) SetProviderOptions(_ fantasy.ProviderOptions) {}
// AdaptedToolsConfig configures how tools are adapted for the Fantasy agent.
type AdaptedToolsConfig struct {
@ -182,7 +182,7 @@ func BuildAdaptedTools(registry *tools.ToolRegistry, msgBus *bus.MessageBus, cha
if !ok {
continue
}
adapted = append(adapted, &PicoToolAdapter{
adapted = append(adapted, &DragonToolAdapter{
inner: tool,
bus: msgBus,
channel: channel,
@ -198,10 +198,10 @@ func BuildAdaptedTools(registry *tools.ToolRegistry, msgBus *bus.MessageBus, cha
// AdaptTools wraps specific Tool instances as Fantasy AgentTools.
// Used by PrepareStep to promote discovered tools to native callables.
func AdaptTools(picoTools []tools.Tool, msgBus *bus.MessageBus, channel, chatID string, cfg AdaptedToolsConfig) []fantasy.AgentTool {
adapted := make([]fantasy.AgentTool, 0, len(picoTools))
for _, tool := range picoTools {
adapted = append(adapted, &PicoToolAdapter{
func AdaptTools(dragonTools []tools.Tool, msgBus *bus.MessageBus, channel, chatID string, cfg AdaptedToolsConfig) []fantasy.AgentTool {
adapted := make([]fantasy.AgentTool, 0, len(dragonTools))
for _, tool := range dragonTools {
adapted = append(adapted, &DragonToolAdapter{
inner: tool,
bus: msgBus,
channel: channel,

View file

@ -100,11 +100,11 @@ func (t *mockNilResultTool) Execute(_ context.Context, _ map[string]interface{})
return nil
}
// --- PicoToolAdapter.Info() Tests ---
// --- DragonToolAdapter.Info() Tests ---
func TestAdapter_Info(t *testing.T) {
t.Parallel()
adapter := &PicoToolAdapter{inner: &mockDualChannelTool{}}
adapter := &DragonToolAdapter{inner: &mockDualChannelTool{}}
info := adapter.Info()
if info.Name != "dual_tool" {
@ -130,7 +130,7 @@ func TestAdapter_Info(t *testing.T) {
func TestAdapter_Info_UnwrapsSchemaWithRequired(t *testing.T) {
t.Parallel()
mock := &mockToolWithRequired{}
adapter := &PicoToolAdapter{inner: mock}
adapter := &DragonToolAdapter{inner: mock}
info := adapter.Info()
if _, ok := info.Parameters["path"]; !ok {
@ -158,12 +158,12 @@ func (t *mockToolWithRequired) Execute(_ context.Context, _ map[string]interface
return &tools.ToolResult{ForLLM: "ok"}
}
// --- PicoToolAdapter.Run() Tests ---
// --- DragonToolAdapter.Run() Tests ---
func TestAdapter_Run_SilentTool_NoPublish(t *testing.T) {
t.Parallel()
msgBus := bus.NewMessageBus()
adapter := &PicoToolAdapter{
adapter := &DragonToolAdapter{
inner: &mockSilentTool{},
bus: msgBus,
channel: "test",
@ -192,7 +192,7 @@ func TestAdapter_Run_SilentTool_NoPublish(t *testing.T) {
func TestAdapter_Run_DualChannel_PublishesForUser(t *testing.T) {
t.Parallel()
msgBus := bus.NewMessageBus()
adapter := &PicoToolAdapter{
adapter := &DragonToolAdapter{
inner: &mockDualChannelTool{},
bus: msgBus,
channel: "telegram",
@ -221,7 +221,7 @@ func TestAdapter_Run_DualChannel_PublishesForUser(t *testing.T) {
func TestAdapter_Run_ErrorTool_ReturnsErrorResponse(t *testing.T) {
t.Parallel()
adapter := &PicoToolAdapter{
adapter := &DragonToolAdapter{
inner: &mockErrorTool{},
}
@ -246,7 +246,7 @@ func TestAdapter_Run_ErrorTool_ReturnsErrorResponse(t *testing.T) {
func TestAdapter_Run_NilResult_ReturnsError(t *testing.T) {
t.Parallel()
adapter := &PicoToolAdapter{
adapter := &DragonToolAdapter{
inner: &mockNilResultTool{},
}
@ -272,7 +272,7 @@ func TestAdapter_Run_NilResult_ReturnsError(t *testing.T) {
func TestAdapter_Run_ContextualTool_SetsContext(t *testing.T) {
t.Parallel()
ctxTool := &mockContextualTool{}
adapter := &PicoToolAdapter{
adapter := &DragonToolAdapter{
inner: ctxTool,
channel: "discord",
chatID: "guild-1",
@ -297,7 +297,7 @@ func TestAdapter_Run_ContextualTool_SetsContext(t *testing.T) {
func TestAdapter_Run_InvalidJSON_ReturnsError(t *testing.T) {
t.Parallel()
adapter := &PicoToolAdapter{
adapter := &DragonToolAdapter{
inner: &mockSilentTool{},
}

View file

@ -428,7 +428,7 @@ func TestAgentResultToMessages_MultipleSteps(t *testing.T) {
// --- Round-trip fidelity test ---
func TestRoundTrip_PicoClawToFantasyAndBack(t *testing.T) {
func TestRoundTrip_DragonScaleToFantasyAndBack(t *testing.T) {
t.Parallel(
// Start with DragonScale messages representing a typical conversation
)

View file

@ -1,5 +1,5 @@
// DragonScale - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// Inspired by and based on picoclaw: https://github.com/sipeed/picoclaw
// License: MIT
//
// Copyright (c) 2026 DragonScale contributors

View file

@ -1,5 +1,5 @@
// DragonScale - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// Inspired by and based on picoclaw: https://github.com/sipeed/picoclaw
// License: MIT
//
// Copyright (c) 2026 DragonScale contributors

View file

@ -22,13 +22,13 @@ const (
)
type Options struct {
DryRun bool
ConfigOnly bool
WorkspaceOnly bool
Force bool
Refresh bool
OpenClawHome string
PicoClawHome string
DryRun bool
ConfigOnly bool
WorkspaceOnly bool
Force bool
Refresh bool
OpenClawHome string
DragonScaleHome string
}
type Action struct {
@ -62,7 +62,7 @@ func Run(opts Options) (*Result, error) {
return nil, err
}
picoClawHome, err := resolvePicoClawHome(opts.PicoClawHome)
dragonscaleHome, err := resolveDragonScaleHome(opts.DragonScaleHome)
if err != nil {
return nil, err
}
@ -71,14 +71,14 @@ func Run(opts Options) (*Result, error) {
return nil, fmt.Errorf("OpenClaw installation not found at %s", openclawHome)
}
actions, warnings, err := Plan(opts, openclawHome, picoClawHome)
actions, warnings, err := Plan(opts, openclawHome, dragonscaleHome)
if err != nil {
return nil, err
}
fmt.Println("Migrating from OpenClaw to DragonScale")
fmt.Printf(" Source: %s\n", openclawHome)
fmt.Printf(" Destination: %s\n", picoClawHome)
fmt.Printf(" Destination: %s\n", dragonscaleHome)
fmt.Println()
if opts.DryRun {
@ -95,12 +95,12 @@ func Run(opts Options) (*Result, error) {
fmt.Println()
}
result := Execute(actions, openclawHome, picoClawHome)
result := Execute(actions, openclawHome, dragonscaleHome)
result.Warnings = warnings
return result, nil
}
func Plan(opts Options, openclawHome, picoClawHome string) ([]Action, []string, error) {
func Plan(opts Options, openclawHome, dragonscaleHome string) ([]Action, []string, error) {
var actions []Action
var warnings []string
@ -117,7 +117,7 @@ func Plan(opts Options, openclawHome, picoClawHome string) ([]Action, []string,
actions = append(actions, Action{
Type: ActionConvertConfig,
Source: configPath,
Destination: filepath.Join(picoClawHome, "config.json"),
Destination: filepath.Join(dragonscaleHome, "config.json"),
Description: "convert OpenClaw config to DragonScale format",
})
@ -131,7 +131,7 @@ func Plan(opts Options, openclawHome, picoClawHome string) ([]Action, []string,
if !opts.ConfigOnly {
srcWorkspace := resolveWorkspace(openclawHome)
dstWorkspace := resolveWorkspace(picoClawHome)
dstWorkspace := resolveWorkspace(dragonscaleHome)
if _, err := os.Stat(srcWorkspace); err == nil {
wsActions, err := PlanWorkspaceMigration(srcWorkspace, dstWorkspace, force)
@ -147,13 +147,13 @@ func Plan(opts Options, openclawHome, picoClawHome string) ([]Action, []string,
return actions, warnings, nil
}
func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
func Execute(actions []Action, openclawHome, dragonscaleHome string) *Result {
result := &Result{}
for _, action := range actions {
switch action.Type {
case ActionConvertConfig:
if err := executeConfigMigration(action.Source, action.Destination, picoClawHome); err != nil {
if err := executeConfigMigration(action.Source, action.Destination, dragonscaleHome); err != nil {
result.Errors = append(result.Errors, fmt.Errorf("config migration: %w", err))
fmt.Printf(" ✗ Config migration failed: %v\n", err)
} else {
@ -207,7 +207,7 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
return result
}
func executeConfigMigration(srcConfigPath, dstConfigPath, picoClawHome string) error {
func executeConfigMigration(srcConfigPath, dstConfigPath, dragonscaleHome string) error {
data, err := LoadOpenClawConfig(srcConfigPath)
if err != nil {
return err
@ -326,7 +326,7 @@ func resolveOpenClawHome(override string) (string, error) {
return filepath.Join(home, ".openclaw"), nil
}
func resolvePicoClawHome(override string) (string, error) {
func resolveDragonScaleHome(override string) (string, error) {
if override != "" {
return expandHome(override), nil
}

View file

@ -579,7 +579,7 @@ func TestRewriteWorkspacePath(t *testing.T) {
func TestRunDryRun(t *testing.T) {
t.Parallel()
openclawHome := t.TempDir()
picoClawHome := t.TempDir()
dragonscaleHome := t.TempDir()
wsDir := filepath.Join(openclawHome, "workspace")
os.MkdirAll(wsDir, 0755)
@ -597,9 +597,9 @@ func TestRunDryRun(t *testing.T) {
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
opts := Options{
DryRun: true,
OpenClawHome: openclawHome,
PicoClawHome: picoClawHome,
DryRun: true,
OpenClawHome: openclawHome,
DragonScaleHome: dragonscaleHome,
}
result, err := Run(opts)
@ -607,11 +607,11 @@ func TestRunDryRun(t *testing.T) {
t.Fatalf("Run: %v", err)
}
picoWs := filepath.Join(picoClawHome, "workspace")
if _, err := os.Stat(filepath.Join(picoWs, "SOUL.md")); !os.IsNotExist(err) {
dragonWs := filepath.Join(dragonscaleHome, "workspace")
if _, err := os.Stat(filepath.Join(dragonWs, "SOUL.md")); !os.IsNotExist(err) {
t.Error("dry run should not create files")
}
if _, err := os.Stat(filepath.Join(picoClawHome, "config.json")); !os.IsNotExist(err) {
if _, err := os.Stat(filepath.Join(dragonscaleHome, "config.json")); !os.IsNotExist(err) {
t.Error("dry run should not create config")
}
@ -621,7 +621,7 @@ func TestRunDryRun(t *testing.T) {
func TestRunFullMigration(t *testing.T) {
t.Parallel()
openclawHome := t.TempDir()
picoClawHome := t.TempDir()
dragonscaleHome := t.TempDir()
wsDir := filepath.Join(openclawHome, "workspace")
os.MkdirAll(wsDir, 0755)
@ -653,9 +653,9 @@ func TestRunFullMigration(t *testing.T) {
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
opts := Options{
Force: true,
OpenClawHome: openclawHome,
PicoClawHome: picoClawHome,
Force: true,
OpenClawHome: openclawHome,
DragonScaleHome: dragonscaleHome,
}
result, err := Run(opts)
@ -663,9 +663,9 @@ func TestRunFullMigration(t *testing.T) {
t.Fatalf("Run: %v", err)
}
picoWs := filepath.Join(picoClawHome, "workspace")
dragonWs := filepath.Join(dragonscaleHome, "workspace")
soulData, err := os.ReadFile(filepath.Join(picoWs, "SOUL.md"))
soulData, err := os.ReadFile(filepath.Join(dragonWs, "SOUL.md"))
if err != nil {
t.Fatalf("reading SOUL.md: %v", err)
}
@ -673,7 +673,7 @@ func TestRunFullMigration(t *testing.T) {
t.Errorf("SOUL.md content = %q, want %q", string(soulData), "# Soul from OpenClaw")
}
agentsData, err := os.ReadFile(filepath.Join(picoWs, "AGENTS.md"))
agentsData, err := os.ReadFile(filepath.Join(dragonWs, "AGENTS.md"))
if err != nil {
t.Fatalf("reading AGENTS.md: %v", err)
}
@ -681,7 +681,7 @@ func TestRunFullMigration(t *testing.T) {
t.Errorf("AGENTS.md content = %q", string(agentsData))
}
memData, err := os.ReadFile(filepath.Join(picoWs, "memory", "MEMORY.md"))
memData, err := os.ReadFile(filepath.Join(dragonWs, "memory", "MEMORY.md"))
if err != nil {
t.Fatalf("reading memory/MEMORY.md: %v", err)
}
@ -689,21 +689,21 @@ func TestRunFullMigration(t *testing.T) {
t.Errorf("MEMORY.md content = %q", string(memData))
}
picoConfig, err := config.LoadConfig(filepath.Join(picoClawHome, "config.json"))
dragonConfig, err := config.LoadConfig(filepath.Join(dragonscaleHome, "config.json"))
if err != nil {
t.Fatalf("loading DragonScale config: %v", err)
}
if picoConfig.Providers.Anthropic.APIKey != "sk-ant-migrate-test" {
t.Errorf("Anthropic.APIKey = %q, want %q", picoConfig.Providers.Anthropic.APIKey, "sk-ant-migrate-test")
if dragonConfig.Providers.Anthropic.APIKey != "sk-ant-migrate-test" {
t.Errorf("Anthropic.APIKey = %q, want %q", dragonConfig.Providers.Anthropic.APIKey, "sk-ant-migrate-test")
}
if picoConfig.Providers.OpenRouter.APIKey != "sk-or-migrate-test" {
t.Errorf("OpenRouter.APIKey = %q, want %q", picoConfig.Providers.OpenRouter.APIKey, "sk-or-migrate-test")
if dragonConfig.Providers.OpenRouter.APIKey != "sk-or-migrate-test" {
t.Errorf("OpenRouter.APIKey = %q, want %q", dragonConfig.Providers.OpenRouter.APIKey, "sk-or-migrate-test")
}
if !picoConfig.Channels.Telegram.Enabled {
if !dragonConfig.Channels.Telegram.Enabled {
t.Error("Telegram should be enabled")
}
if picoConfig.Channels.Telegram.Token != "tg-migrate-test" {
t.Errorf("Telegram.Token = %q, want %q", picoConfig.Channels.Telegram.Token, "tg-migrate-test")
if dragonConfig.Channels.Telegram.Token != "tg-migrate-test" {
t.Errorf("Telegram.Token = %q, want %q", dragonConfig.Channels.Telegram.Token, "tg-migrate-test")
}
if result.FilesCopied < 3 {
@ -720,8 +720,8 @@ func TestRunFullMigration(t *testing.T) {
func TestRunOpenClawNotFound(t *testing.T) {
t.Parallel()
opts := Options{
OpenClawHome: "/nonexistent/path/to/openclaw",
PicoClawHome: t.TempDir(),
OpenClawHome: "/nonexistent/path/to/openclaw",
DragonScaleHome: t.TempDir(),
}
_, err := Run(opts)
@ -787,7 +787,7 @@ func TestCopyFile(t *testing.T) {
func TestRunConfigOnly(t *testing.T) {
t.Parallel()
openclawHome := t.TempDir()
picoClawHome := t.TempDir()
dragonscaleHome := t.TempDir()
wsDir := filepath.Join(openclawHome, "workspace")
os.MkdirAll(wsDir, 0755)
@ -804,10 +804,10 @@ func TestRunConfigOnly(t *testing.T) {
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
opts := Options{
Force: true,
ConfigOnly: true,
OpenClawHome: openclawHome,
PicoClawHome: picoClawHome,
Force: true,
ConfigOnly: true,
OpenClawHome: openclawHome,
DragonScaleHome: dragonscaleHome,
}
result, err := Run(opts)
@ -819,8 +819,8 @@ func TestRunConfigOnly(t *testing.T) {
t.Error("config should have been migrated")
}
picoWs := filepath.Join(picoClawHome, "workspace")
if _, err := os.Stat(filepath.Join(picoWs, "SOUL.md")); !os.IsNotExist(err) {
dragonWs := filepath.Join(dragonscaleHome, "workspace")
if _, err := os.Stat(filepath.Join(dragonWs, "SOUL.md")); !os.IsNotExist(err) {
t.Error("config-only should not copy workspace files")
}
}
@ -828,7 +828,7 @@ func TestRunConfigOnly(t *testing.T) {
func TestRunWorkspaceOnly(t *testing.T) {
t.Parallel()
openclawHome := t.TempDir()
picoClawHome := t.TempDir()
dragonscaleHome := t.TempDir()
wsDir := filepath.Join(openclawHome, "workspace")
os.MkdirAll(wsDir, 0755)
@ -845,10 +845,10 @@ func TestRunWorkspaceOnly(t *testing.T) {
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
opts := Options{
Force: true,
WorkspaceOnly: true,
OpenClawHome: openclawHome,
PicoClawHome: picoClawHome,
Force: true,
WorkspaceOnly: true,
OpenClawHome: openclawHome,
DragonScaleHome: dragonscaleHome,
}
result, err := Run(opts)
@ -860,8 +860,8 @@ func TestRunWorkspaceOnly(t *testing.T) {
t.Error("workspace-only should not migrate config")
}
picoWs := filepath.Join(picoClawHome, "workspace")
soulData, err := os.ReadFile(filepath.Join(picoWs, "SOUL.md"))
dragonWs := filepath.Join(dragonscaleHome, "workspace")
soulData, err := os.ReadFile(filepath.Join(dragonWs, "SOUL.md"))
if err != nil {
t.Fatalf("reading SOUL.md: %v", err)
}

View file

@ -9,7 +9,7 @@ import (
"github.com/ZanzyTHEbar/dragonscale/pkg/agent"
"github.com/ZanzyTHEbar/dragonscale/pkg/bus"
"github.com/ZanzyTHEbar/dragonscale/pkg/config"
picofantasy "github.com/ZanzyTHEbar/dragonscale/pkg/fantasy"
dragonfantasy "github.com/ZanzyTHEbar/dragonscale/pkg/fantasy"
)
type OutboundMode string
@ -72,13 +72,13 @@ func Bootstrap(parent context.Context, cfg *config.Config, opts BootstrapOptions
ctx, cancel := withExecutionContext(parent, opts.Timeout)
provider, err := picofantasy.CreateProvider(cfg)
provider, err := dragonfantasy.CreateProvider(cfg)
if err != nil {
cancel()
return nil, fmt.Errorf("provider error: %w", err)
}
model, err := provider.LanguageModel(ctx, picofantasy.ModelID(cfg))
model, err := provider.LanguageModel(ctx, dragonfantasy.ModelID(cfg))
if err != nil {
cancel()
return nil, fmt.Errorf("model error: %w", err)

View file

@ -6,10 +6,15 @@ import (
"path/filepath"
"time"
"github.com/ZanzyTHEbar/dragonscale/pkg"
"github.com/ZanzyTHEbar/dragonscale/pkg/config"
)
const EvalConfigEnvVar = "DRAGONSCALE_EVAL_CONFIG"
const (
EvalConfigEnvVar = "DRAGONSCALE_EVAL_CONFIG"
EvalBaseConfigEnvVar = "DRAGONSCALE_EVAL_BASE_CONFIG"
EvalHostHomeEnvVar = "DRAGONSCALE_EVAL_HOST_HOME"
)
type LoadConfigOptions struct {
BaseConfigPath string
@ -18,24 +23,51 @@ type LoadConfigOptions struct {
}
func ResolveBaseConfigPath() string {
if explicit := os.Getenv(EvalBaseConfigEnvVar); explicit != "" {
if _, err := os.Stat(explicit); err == nil {
return explicit
}
}
checkHostConfig := func(hostHome string) string {
if hostHome == "" {
return ""
}
// XDG standard path.
hostXDG := filepath.Join(hostHome, ".config", pkg.NAME, "config.json")
if _, err := os.Stat(hostXDG); err == nil {
return hostXDG
}
return ""
}
// Prefer host-mounted config when running in containerized eval.
if hostHome := os.Getenv(EvalHostHomeEnvVar); hostHome != "" {
if path := checkHostConfig(hostHome); path != "" {
return path
}
}
// Fall back to the default devcontainer host mount location.
if path := checkHostConfig("/host_home"); path != "" {
return path
}
// Prefer XDG standard path (~/.config/dragonscale/config.json) when present.
if xdgPath, err := config.DefaultConfigPath(); err == nil {
xdgPath, err := config.DefaultConfigPath()
if err == nil {
if _, statErr := os.Stat(xdgPath); statErr == nil {
return xdgPath
}
}
home, _ := os.UserHomeDir()
legacy := filepath.Join(home, ".dragonscale", "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 {
if err == nil {
return xdgPath
}
return legacy
return ""
}
func LoadResolvedConfig(opts LoadConfigOptions) (*config.Config, error) {

View file

@ -44,18 +44,139 @@ func TestResolveBaseConfigPath_PrefersXDGOverLegacy(t *testing.T) {
assert.Empty(t, cmp.Diff(xdgPath, got))
}
func TestResolveBaseConfigPath_FallsBackToLegacyWhenXDGMissing(t *testing.T) {
func TestResolveBaseConfigPath_FallsBackToXDGWhenLegacyOnlyExists(t *testing.T) {
home := t.TempDir()
xdg := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", xdg)
xdgPath := filepath.Join(xdg, pkg.NAME, "config.json")
legacyPath := filepath.Join(home, ".dragonscale", "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.Empty(t, cmp.Diff(legacyPath, got))
assert.Empty(t, cmp.Diff(xdgPath, got))
assert.NotEmpty(t, legacyPath)
}
func TestResolveBaseConfigPath_UsesEvalHostHomeWhenContainerConfigMissing(t *testing.T) {
home := t.TempDir()
xdg := t.TempDir()
hostHome := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", xdg)
t.Setenv(EvalHostHomeEnvVar, hostHome)
hostXDGConfig := filepath.Join(hostHome, ".config", pkg.NAME, "config.json")
require.NoError(t, os.MkdirAll(filepath.Dir(hostXDGConfig), 0o755))
require.NoError(t, os.WriteFile(hostXDGConfig, []byte(`{}`), 0o644))
got := ResolveBaseConfigPath()
assert.Empty(t, cmp.Diff(hostXDGConfig, got))
}
func TestResolveBaseConfigPath_PrefersEvalHostHomeOverContainerConfig(t *testing.T) {
home := t.TempDir()
xdg := t.TempDir()
hostHome := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", xdg)
t.Setenv(EvalHostHomeEnvVar, hostHome)
containerXDG := filepath.Join(xdg, pkg.NAME, "config.json")
hostXDG := filepath.Join(hostHome, ".config", pkg.NAME, "config.json")
require.NoError(t, os.MkdirAll(filepath.Dir(containerXDG), 0o755))
require.NoError(t, os.MkdirAll(filepath.Dir(hostXDG), 0o755))
require.NoError(t, os.WriteFile(containerXDG, []byte(`{}`), 0o644))
require.NoError(t, os.WriteFile(hostXDG, []byte(`{}`), 0o644))
got := ResolveBaseConfigPath()
assert.Empty(t, cmp.Diff(hostXDG, got))
}
func TestResolveBaseConfigPath_DoesNotUseLegacyHostPathForHostHome(t *testing.T) {
home := t.TempDir()
xdg := t.TempDir()
hostHome := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", xdg)
t.Setenv(EvalHostHomeEnvVar, hostHome)
containerXDG := filepath.Join(xdg, pkg.NAME, "config.json")
hostLegacy := filepath.Join(hostHome, ".dragonscale", "config.json")
require.NoError(t, os.MkdirAll(filepath.Dir(containerXDG), 0o755))
require.NoError(t, os.MkdirAll(filepath.Dir(hostLegacy), 0o755))
require.NoError(t, os.WriteFile(containerXDG, []byte(`{}`), 0o644))
require.NoError(t, os.WriteFile(hostLegacy, []byte(`{}`), 0o644))
got := ResolveBaseConfigPath()
assert.Empty(t, cmp.Diff(containerXDG, got))
}
func TestResolveBaseConfigPath_UsesEvalBaseConfigOverride(t *testing.T) {
override := filepath.Join(t.TempDir(), "explicit-config.json")
require.NoError(t, os.WriteFile(override, []byte(`{}`), 0o644))
t.Setenv(EvalBaseConfigEnvVar, override)
got := ResolveBaseConfigPath()
assert.Empty(t, cmp.Diff(override, got))
}
func TestResolveBaseConfigPath_UsesEvalBaseConfigUnsetAsFallback(t *testing.T) {
home := t.TempDir()
xdg := t.TempDir()
hostHome := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", xdg)
t.Setenv(EvalHostHomeEnvVar, hostHome)
hostXDG := filepath.Join(hostHome, ".config", pkg.NAME, "config.json")
require.NoError(t, os.MkdirAll(filepath.Dir(hostXDG), 0o755))
require.NoError(t, os.WriteFile(hostXDG, []byte(`{}`), 0o644))
t.Setenv(EvalBaseConfigEnvVar, "")
got := ResolveBaseConfigPath()
assert.Empty(t, cmp.Diff(hostXDG, got))
}
func TestResolveBaseConfigPath_InvalidEvalBaseConfigFallsBackToHostConfig(t *testing.T) {
home := t.TempDir()
xdg := t.TempDir()
hostHome := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", xdg)
t.Setenv(EvalHostHomeEnvVar, hostHome)
hostXDG := filepath.Join(hostHome, ".config", pkg.NAME, "config.json")
require.NoError(t, os.MkdirAll(filepath.Dir(hostXDG), 0o755))
require.NoError(t, os.WriteFile(hostXDG, []byte(`{}`), 0o644))
t.Setenv(EvalBaseConfigEnvVar, filepath.Join(t.TempDir(), "no-such-file.json"))
got := ResolveBaseConfigPath()
assert.Empty(t, cmp.Diff(hostXDG, got))
}
func TestLoadResolvedConfig_FallsBackWhenExplicitBaseConfigInvalid(t *testing.T) {
workDir := t.TempDir()
hostDir := t.TempDir()
home := t.TempDir()
xdg := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", xdg)
t.Setenv(EvalHostHomeEnvVar, hostDir)
basePath := filepath.Join(hostDir, ".config", pkg.NAME, "config.json")
require.NoError(t, os.MkdirAll(filepath.Dir(basePath), 0o755))
require.NoError(t, os.WriteFile(basePath, []byte(`{"agents":{"defaults":{"restrict_to_sandbox":true,"max_tool_iterations":20}}}`), 0o644))
t.Setenv(EvalBaseConfigEnvVar, filepath.Join(workDir, "does-not-exist.json"))
cfg, err := LoadResolvedConfig(LoadConfigOptions{MinProviderTimeout: 2 * time.Second})
require.NoError(t, err)
assert.Empty(t, cmp.Diff(true, cfg.Agents.Defaults.RestrictToSandbox))
}
func TestLoadResolvedConfig_AppliesOverlayAndKeepsBaseValues(t *testing.T) {

View file

@ -91,7 +91,7 @@ func TestSyncAll_InsertsNewFiles(t *testing.T) {
"AGENT.md": "# Agent\nYou are helpful.",
"SOUL.md": "# Soul\nCurious and kind.",
"USER.md": "# User\nName: Alice",
"IDENTITY.md": "# Identity\nPicoClaw v1",
"IDENTITY.md": "# Identity\nDragonScale v1",
})
store := newMockStore()