Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"neo-code/internal/provider"
"neo-code/internal/provider/builtin"
agentruntime "neo-code/internal/runtime"
"neo-code/internal/security"
"neo-code/internal/tools"
"neo-code/internal/tools/bash"
"neo-code/internal/tools/filesystem"
Expand All @@ -27,6 +28,10 @@ func NewProgram(ctx context.Context) (*tea.Program, error) {
}

toolRegistry := buildToolRegistry(cfg)
toolManager, err := buildToolManager(toolRegistry)
if err != nil {
return nil, err
}

providerRegistry, err := builtin.NewRegistry()
if err != nil {
Expand All @@ -35,7 +40,13 @@ func NewProgram(ctx context.Context) (*tea.Program, error) {
providerService := provider.NewService(manager, providerRegistry)

sessionStore := agentruntime.NewSessionStore(loader.BaseDir())
runtimeSvc := agentruntime.NewWithFactory(manager, toolRegistry, sessionStore, providerService, agentcontext.NewBuilder())
runtimeSvc := agentruntime.NewWithFactory(
manager,
toolManager,
sessionStore,
providerService,
agentcontext.NewBuilder(),
)

tuiApp, err := tui.New(&cfg, manager, runtimeSvc, providerService)
if err != nil {
Expand Down Expand Up @@ -63,3 +74,11 @@ func buildToolRegistry(cfg config.Config) *tools.Registry {
}))
return toolRegistry
}

func buildToolManager(registry *tools.Registry) (tools.Manager, error) {
engine, err := security.NewStaticGateway(security.DecisionAllow, nil)
if err != nil {
return nil, err
}
return tools.NewManager(registry, engine, nil)
}
45 changes: 45 additions & 0 deletions internal/app/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,48 @@ func TestBuildToolRegistryUsesWebFetchConfig(t *testing.T) {
t.Fatalf("expected formatted webfetch content")
}
}

func TestBuildToolManagerWrapsRegistry(t *testing.T) {
t.Parallel()

registry := tools.NewRegistry()
registry.Register(stubToolForBootstrap{name: "bash", content: "ok"})
manager, err := buildToolManager(registry)
if err != nil {
t.Fatalf("buildToolManager() error = %v", err)
}
if manager == nil {
t.Fatalf("expected tool manager")
}

specs, err := manager.ListAvailableSpecs(context.Background(), tools.SpecListInput{})
if err != nil {
t.Fatalf("ListAvailableSpecs() error = %v", err)
}
if len(specs) != 1 {
t.Fatalf("expected 1 spec, got %+v", specs)
}

result, execErr := manager.Execute(context.Background(), tools.ToolCallInput{
Name: "bash",
Arguments: []byte(`{"command":"echo hi"}`),
})
if execErr != nil {
t.Fatalf("Execute() error = %v", execErr)
}
if result.Content != "ok" {
t.Fatalf("expected ok result, got %+v", result)
}
}

type stubToolForBootstrap struct {
name string
content string
}

func (s stubToolForBootstrap) Name() string { return s.name }
func (s stubToolForBootstrap) Description() string { return "stub" }
func (s stubToolForBootstrap) Schema() map[string]any { return map[string]any{"type": "object"} }
func (s stubToolForBootstrap) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) {
return tools.ToolResult{Name: s.name, Content: s.content}, nil
}
21 changes: 15 additions & 6 deletions internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ type ProviderFactory interface {
type Service struct {
configManager *config.Manager // 配置管理器,提供当前选中的 provider、model、workdir 等配置读取能力。
sessionStore Store // 会话持久化接口,负责保存和加载聊天会话。
toolRegistry *tools.Registry // 工具注册表,维护所有工具的 schema。
toolManager tools.Manager // 工具管理器,统一工具 schema 暴露与执行入口
providerFactory ProviderFactory // Provider 工厂接口,根据配置动态创建具体的 provider 实例。
contextBuilder agentcontext.Builder // 上下文构建器,负责组装 system prompt 与本轮发给模型的消息上下文。
events chan RuntimeEvent // 事件通道,Runtime 在运行过程中产生的事件都通过该通道发送给 TUI 层消费和展示。
Expand All @@ -61,22 +61,25 @@ type Service struct {

func NewWithFactory(
configManager *config.Manager,
toolRegistry *tools.Registry,
toolManager tools.Manager,
sessionStore Store,
providerFactory ProviderFactory,
contextBuilder agentcontext.Builder,
) *Service {
if providerFactory == nil {
providerFactory = provider.NewRegistry()
}
if toolManager == nil {
toolManager = tools.NewRegistry()
}
if contextBuilder == nil {
contextBuilder = agentcontext.NewBuilder()
}

return &Service{
configManager: configManager,
sessionStore: sessionStore,
toolRegistry: toolRegistry,
toolManager: toolManager,
providerFactory: providerFactory,
contextBuilder: contextBuilder,
events: make(chan RuntimeEvent, 128),
Expand Down Expand Up @@ -136,12 +139,18 @@ func (s *Service) Run(ctx context.Context, input UserInput) error {
return s.handleRunError(ctx, input.RunID, session.ID, err)
}

resp, err := s.callProviderWithRetry(ctx, input.RunID, session.ID, provider.ChatRequest{
toolSpecs, err := s.toolManager.ListAvailableSpecs(ctx, tools.SpecListInput{
SessionID: session.ID,
})
if err != nil {
return s.handleRunError(ctx, input.RunID, session.ID, err)
}

resp, err := s.callProviderWithRetry(ctx, input.RunID, session.ID, provider.ChatRequest{
Model: cfg.CurrentModel,
SystemPrompt: builtContext.SystemPrompt,
Messages: builtContext.Messages,
Tools: s.toolRegistry.GetSpecs(),
Tools: toolSpecs,
})
if err != nil {
return s.handleRunError(ctx, input.RunID, session.ID, err)
Expand Down Expand Up @@ -178,7 +187,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error {
s.emit(ctx, EventToolStart, input.RunID, session.ID, call)

runCtx, cancel := context.WithTimeout(ctx, time.Duration(cfg.ToolTimeoutSec)*time.Second)
result, execErr := s.toolRegistry.Execute(runCtx, tools.ToolCallInput{
result, execErr := s.toolManager.Execute(runCtx, tools.ToolCallInput{
ID: call.ID,
Name: call.Name,
Arguments: []byte(call.Arguments),
Expand Down
194 changes: 185 additions & 9 deletions internal/runtime/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"strings"
"testing"
"time"

"neo-code/internal/config"
agentcontext "neo-code/internal/context"
Expand Down Expand Up @@ -192,6 +193,37 @@ func (b *stubContextBuilder) Build(ctx context.Context, input agentcontext.Build
}, nil
}

type stubToolManager struct {
specs []provider.ToolSpec
result tools.ToolResult
err error
listErr error
listCalls int
executeCalls int
lastInput tools.ToolCallInput
}

func (m *stubToolManager) ListAvailableSpecs(ctx context.Context, input tools.SpecListInput) ([]provider.ToolSpec, error) {
m.listCalls++
if err := ctx.Err(); err != nil {
return nil, err
}
if m.listErr != nil {
return nil, m.listErr
}
return append([]provider.ToolSpec(nil), m.specs...), nil
}

func (m *stubToolManager) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) {
m.executeCalls++
m.lastInput = input
result := m.result
if result.Name == "" {
result.Name = input.Name
}
return result, m.err
}

func TestServiceRun(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -430,6 +462,125 @@ func TestServiceRunDelegatesToContextBuilder(t *testing.T) {
}
}

func TestServiceRunUsesToolManager(t *testing.T) {
t.Parallel()

manager := newRuntimeConfigManager(t)
store := newMemoryStore()
toolManager := &stubToolManager{
specs: []provider.ToolSpec{
{Name: "filesystem_edit", Description: "stub", Schema: map[string]any{"type": "object"}},
},
result: tools.ToolResult{
Name: "filesystem_edit",
Content: "tool manager output",
},
}

scripted := &scriptedProvider{
responses: []provider.ChatResponse{
{
Message: provider.Message{
Role: "assistant",
ToolCalls: []provider.ToolCall{
{ID: "call-manager", Name: "filesystem_edit", Arguments: `{"path":"main.go"}`},
},
},
FinishReason: "tool_calls",
},
{
Message: provider.Message{
Role: "assistant",
Content: "done",
},
FinishReason: "stop",
},
},
}

service := NewWithFactory(manager, toolManager, store, &scriptedProviderFactory{provider: scripted}, nil)
if err := service.Run(context.Background(), UserInput{RunID: "run-tool-manager", Content: "edit file"}); err != nil {
t.Fatalf("Run() error = %v", err)
}

if toolManager.listCalls != 2 {
t.Fatalf("expected 2 spec list calls, got %d", toolManager.listCalls)
}
if toolManager.executeCalls != 1 {
t.Fatalf("expected 1 execute call, got %d", toolManager.executeCalls)
}
if toolManager.lastInput.ID != "call-manager" {
t.Fatalf("expected forwarded tool call id, got %q", toolManager.lastInput.ID)
}
if len(scripted.requests) == 0 || len(scripted.requests[0].Tools) != 1 || scripted.requests[0].Tools[0].Name != "filesystem_edit" {
t.Fatalf("expected tool specs from tool manager, got %+v", scripted.requests)
}

session := onlySession(t, store)
foundToolMessage := false
for _, message := range session.Messages {
if message.Role == provider.RoleTool && message.Content == "tool manager output" {
foundToolMessage = true
break
}
}
if !foundToolMessage {
t.Fatalf("expected tool manager result in session messages, got %+v", session.Messages)
}
}

func TestServiceRunHandlesToolManagerSpecError(t *testing.T) {
manager := newRuntimeConfigManager(t)
store := newMemoryStore()
toolManager := &stubToolManager{
listErr: errors.New("tool specs unavailable"),
}

service := NewWithFactory(manager, toolManager, store, &scriptedProviderFactory{
provider: &scriptedProvider{},
}, nil)
input := UserInput{RunID: "run-tool-spec-error", Content: "hello"}
err := service.Run(context.Background(), input)
if err == nil || !containsError(err, "tool specs unavailable") {
t.Fatalf("expected tool spec error, got %v", err)
}

events := collectRuntimeEvents(service.Events())
assertEventSequence(t, events, []EventType{EventUserMessage, EventError})
assertNoEventType(t, events, EventAgentDone)
assertEventsRunID(t, events, input.RunID)

session := onlySession(t, store)
if len(session.Messages) != 1 || session.Messages[0].Role != provider.RoleUser {
t.Fatalf("expected only user message to persist, got %+v", session.Messages)
}
}

func TestServiceNewWithFactoryDefaultsToolManager(t *testing.T) {
manager := newRuntimeConfigManager(t)
store := newMemoryStore()
service := NewWithFactory(manager, nil, store, &scriptedProviderFactory{
provider: &scriptedProvider{
responses: []provider.ChatResponse{
{
Message: provider.Message{
Role: provider.RoleAssistant,
Content: "done",
},
FinishReason: "stop",
},
},
},
}, nil)

if err := service.Run(context.Background(), UserInput{RunID: "run-default-tool-manager", Content: "hello"}); err != nil {
t.Fatalf("Run() error = %v", err)
}

events := collectRuntimeEvents(service.Events())
assertEventSequence(t, events, []EventType{EventUserMessage, EventAgentDone})
}

func TestServiceRunErrorPaths(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -1213,16 +1364,41 @@ func TestIsRetryableProviderError(t *testing.T) {
func TestProviderRetryBackoff(t *testing.T) {
t.Parallel()

first := providerRetryBackoff(1)
second := providerRetryBackoff(2)

if second <= first {
t.Fatalf("expected backoff to increase: first=%v second=%v", first, second)
tests := []struct {
name string
attempt int
min time.Duration
max time.Duration
}{
{
name: "first retry stays within jittered base window",
attempt: 1,
min: 500 * time.Millisecond,
max: 1500 * time.Millisecond,
},
{
name: "second retry stays within jittered doubled window",
attempt: 2,
min: 1 * time.Second,
max: 3 * time.Second,
},
{
name: "large retry is capped at max wait",
attempt: 20,
min: providerRetryMaxWait,
max: providerRetryMaxWait,
},
}

// 验证不超过 MaxWait
large := providerRetryBackoff(20)
if large > providerRetryMaxWait {
t.Fatalf("expected backoff <= MaxWait, got %v > %v", large, providerRetryMaxWait)
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

got := providerRetryBackoff(tt.attempt)
if got < tt.min || got > tt.max {
t.Fatalf("providerRetryBackoff(%d) = %v, want within [%v, %v]", tt.attempt, got, tt.min, tt.max)
}
})
}
}
Loading
Loading