From a64d8f9dffa61089e76c6b99be9a0bbf603dec34 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 1 Apr 2026 15:37:01 +0800 Subject: [PATCH 1/3] refactor(runtime): extract context builder from runtime --- README.md | 2 + docs/runtime-provider-event-flow.md | 16 ++- internal/app/bootstrap.go | 3 +- internal/context/builder.go | 23 ++++ internal/context/builder_test.go | 185 +++++++++++++++++++++++++ internal/context/prompt.go | 10 ++ internal/context/trim.go | 37 +++++ internal/context/types.go | 24 ++++ internal/runtime/runtime.go | 93 +++++-------- internal/runtime/runtime_test.go | 201 +++++++++++++--------------- 10 files changed, 420 insertions(+), 174 deletions(-) create mode 100644 internal/context/builder.go create mode 100644 internal/context/builder_test.go create mode 100644 internal/context/prompt.go create mode 100644 internal/context/trim.go create mode 100644 internal/context/types.go diff --git a/README.md b/README.md index 41317fa7..f50c6e3c 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ go run ./cmd/neocode 核心模块职责: - **`internal/config`** — 配置管理、环境变量、YAML 加载 +- **`internal/context`** — system prompt、消息裁剪与上下文构建 - **`internal/provider`** — LLM 提供商抽象,抹平厂商差异 - **`internal/runtime`** — ReAct 主循环、事件流、会话管理 - **`internal/tools`** — 工具注册表与具体工具实现 @@ -84,6 +85,7 @@ go run ./cmd/neocode ├── internal │ ├── app # 应用装配 │ ├── config # 配置管理 +│ ├── context # 上下文构建 │ ├── provider # Provider 抽象与实现 │ ├── runtime # ReAct 循环与事件流 │ ├── tools # 工具系统 diff --git a/docs/runtime-provider-event-flow.md b/docs/runtime-provider-event-flow.md index 14f1fd67..e4f0a982 100644 --- a/docs/runtime-provider-event-flow.md +++ b/docs/runtime-provider-event-flow.md @@ -1,6 +1,9 @@ # Runtime 与 Provider 事件流设计 + ## Runtime 事件类型 + 当前 runtime 对外暴露一组小而稳定的事件: + - `agent_chunk` - `agent_done` - `tool_start` @@ -8,22 +11,25 @@ - `error` ## ReAct 主循环 -1. 加载目标会话或创建草稿会话。 + +1. 加载目标会话或创建新会话。 2. 追加最新的用户消息。 3. 读取最新配置快照。 -4. 通过 runtime 内部的 Provider 构建逻辑实例化当前模型客户端。 -5. 在不破坏 Tool Call / Tool Result 配对关系的前提下裁剪上下文。 +4. 解析当前 provider 配置并构建 provider 实例。 +5. 调用 `context.Builder` 生成本轮请求使用的 `system prompt` 和消息上下文。 6. 调用 `Provider.Chat`,并把流式事件桥接给 TUI。 7. 保存 assistant 完整回复。 8. 执行返回的工具调用,并保存每一个工具结果。 -9. 若仍需继续推理,则继续下一轮;否则结束。 +9. 如果仍需继续推理,则进入下一轮;否则结束。 ## 流式桥接 + - Provider 发出 `StreamEvent` - runtime 将其转换成 `RuntimeEvent` -- TUI 使用一次性 Bubble Tea `Cmd` 监听一个事件,并在处理完后再次订阅 +- TUI 使用 Bubble Tea `Cmd` 监听事件,并在处理完成后继续订阅 ## 持久化时机 + - 用户消息提交后保存 - assistant 完整回复后保存 - 每个工具结果完成后保存 diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 67e586a6..4203b881 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -7,6 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "neo-code/internal/config" + agentcontext "neo-code/internal/context" "neo-code/internal/provider" "neo-code/internal/provider/builtin" agentruntime "neo-code/internal/runtime" @@ -34,7 +35,7 @@ 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) + runtimeSvc := agentruntime.NewWithFactory(manager, toolRegistry, sessionStore, providerService, agentcontext.NewBuilder()) tuiApp, err := tui.New(&cfg, manager, runtimeSvc, providerService) if err != nil { diff --git a/internal/context/builder.go b/internal/context/builder.go new file mode 100644 index 00000000..de106976 --- /dev/null +++ b/internal/context/builder.go @@ -0,0 +1,23 @@ +package context + +import "context" + +// DefaultBuilder preserves the current runtime context-building behavior. +type DefaultBuilder struct{} + +// NewBuilder returns the default context builder implementation. +func NewBuilder() Builder { + return &DefaultBuilder{} +} + +// Build assembles the provider-facing context for the current round. +func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResult, error) { + if err := ctx.Err(); err != nil { + return BuildResult{}, err + } + + return BuildResult{ + SystemPrompt: defaultSystemPrompt(), + Messages: trimMessages(input.Messages), + }, nil +} diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go new file mode 100644 index 00000000..fe23ed26 --- /dev/null +++ b/internal/context/builder_test.go @@ -0,0 +1,185 @@ +package context + +import ( + stdcontext "context" + "fmt" + "testing" + + "neo-code/internal/provider" +) + +func TestDefaultBuilderBuild(t *testing.T) { + t.Parallel() + + builder := NewBuilder() + input := BuildInput{ + Messages: []provider.Message{ + {Role: "user", Content: "hello"}, + }, + Workdir: t.TempDir(), + } + + got, err := builder.Build(stdcontext.Background(), input) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if got.SystemPrompt == "" { + t.Fatalf("expected non-empty system prompt") + } + if got.SystemPrompt != defaultSystemPrompt() { + t.Fatalf("expected default prompt to remain unchanged") + } + if len(got.Messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(got.Messages)) + } + if &got.Messages[0] == &input.Messages[0] { + t.Fatalf("expected messages slice to be cloned") + } +} + +func TestDefaultBuilderBuildHonorsCancellation(t *testing.T) { + t.Parallel() + + builder := NewBuilder() + ctx, cancel := stdcontext.WithCancel(stdcontext.Background()) + cancel() + + _, err := builder.Build(ctx, BuildInput{}) + if err != stdcontext.Canceled { + t.Fatalf("expected context.Canceled, got %v", err) + } +} + +func TestTrimMessagesPreservesToolPairs(t *testing.T) { + t.Parallel() + + messages := make([]provider.Message, 0, maxContextTurns+4) + for i := 0; i < 8; i++ { + messages = append(messages, provider.Message{Role: "user", Content: fmt.Sprintf("u-%d", i)}) + } + messages = append(messages, + provider.Message{ + Role: "assistant", + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "filesystem_edit", Arguments: "{}"}, + }, + }, + provider.Message{Role: "tool", ToolCallID: "call-1", Content: "tool-result"}, + provider.Message{Role: "assistant", Content: "after-tool"}, + provider.Message{Role: "user", Content: "latest"}, + ) + + trimmed := trimMessages(messages) + if len(trimmed) > len(messages) { + t.Fatalf("trimmed messages should not grow") + } + + foundAssistantToolCall := false + foundToolResult := false + for _, message := range trimmed { + if message.Role == "assistant" && len(message.ToolCalls) > 0 { + foundAssistantToolCall = true + } + if message.Role == "tool" && message.ToolCallID == "call-1" { + foundToolResult = true + } + } + if foundAssistantToolCall != foundToolResult { + t.Fatalf("expected tool call and tool result to be preserved together, got %+v", trimmed) + } +} + +func TestTrimMessagesBoundaries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input []provider.Message + wantLen int + assert func(t *testing.T, original []provider.Message, trimmed []provider.Message) + }{ + { + name: "within max turns returns full cloned slice", + input: []provider.Message{ + {Role: "user", Content: "one"}, + {Role: "assistant", Content: "two"}, + }, + wantLen: 2, + assert: func(t *testing.T, original []provider.Message, trimmed []provider.Message) { + t.Helper() + if &trimmed[0] == &original[0] { + t.Fatalf("expected trimmed slice to be cloned") + } + }, + }, + { + name: "long message list with limited spans keeps full history", + input: func() []provider.Message { + messages := make([]provider.Message, 0, maxContextTurns+3) + for i := 0; i < maxContextTurns-1; i++ { + messages = append(messages, provider.Message{Role: "user", Content: fmt.Sprintf("u-%d", i)}) + } + messages = append(messages, + provider.Message{ + Role: "assistant", + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "filesystem_edit", Arguments: "{}"}, + }, + }, + provider.Message{Role: "tool", ToolCallID: "call-1", Content: "tool-1"}, + provider.Message{Role: "tool", ToolCallID: "call-1", Content: "tool-2"}, + ) + return messages + }(), + wantLen: maxContextTurns + 2, + assert: func(t *testing.T, original []provider.Message, trimmed []provider.Message) { + t.Helper() + if len(trimmed) != len(original) { + t.Fatalf("expected full history to remain, got %d want %d", len(trimmed), len(original)) + } + }, + }, + { + name: "message count beyond limit trims by span count", + input: func() []provider.Message { + messages := make([]provider.Message, 0, maxContextTurns+5) + for i := 0; i < maxContextTurns+1; i++ { + messages = append(messages, provider.Message{Role: "user", Content: fmt.Sprintf("u-%d", i)}) + } + messages = append(messages, + provider.Message{ + Role: "assistant", + ToolCalls: []provider.ToolCall{ + {ID: "call-2", Name: "filesystem_edit", Arguments: "{}"}, + }, + }, + provider.Message{Role: "tool", ToolCallID: "call-2", Content: "tool-result"}, + ) + return messages + }(), + wantLen: maxContextTurns + 1, + assert: func(t *testing.T, original []provider.Message, trimmed []provider.Message) { + t.Helper() + if trimmed[0].Content != "u-2" { + t.Fatalf("expected oldest spans to be removed, got first message %+v", trimmed[0]) + } + if trimmed[len(trimmed)-1].Role != "tool" { + t.Fatalf("expected trailing tool result to remain, got %+v", trimmed[len(trimmed)-1]) + } + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + trimmed := trimMessages(tt.input) + if len(trimmed) != tt.wantLen { + t.Fatalf("expected len %d, got %d", tt.wantLen, len(trimmed)) + } + if tt.assert != nil { + tt.assert(t, tt.input, trimmed) + } + }) + } +} diff --git a/internal/context/prompt.go b/internal/context/prompt.go new file mode 100644 index 00000000..db01563c --- /dev/null +++ b/internal/context/prompt.go @@ -0,0 +1,10 @@ +package context + +func defaultSystemPrompt() string { + return `You are NeoCode, a local coding agent. + + Be concise and accurate. + Use tools when necessary. + When a tool fails, inspect the error and continue safely. + Stay within the workspace and avoid destructive behavior unless clearly requested.` +} diff --git a/internal/context/trim.go b/internal/context/trim.go new file mode 100644 index 00000000..aa403699 --- /dev/null +++ b/internal/context/trim.go @@ -0,0 +1,37 @@ +package context + +import "neo-code/internal/provider" + +const maxContextTurns = 10 + +func trimMessages(messages []provider.Message) []provider.Message { + if len(messages) <= maxContextTurns { + return append([]provider.Message(nil), messages...) + } + + type span struct { + start int + end int + } + + spans := make([]span, 0, len(messages)) + for i := 0; i < len(messages); { + start := i + i++ + + if messages[start].Role == "assistant" && len(messages[start].ToolCalls) > 0 { + for i < len(messages) && messages[i].Role == "tool" { + i++ + } + } + + spans = append(spans, span{start: start, end: i}) + } + + if len(spans) <= maxContextTurns { + return append([]provider.Message(nil), messages...) + } + + start := spans[len(spans)-maxContextTurns].start + return append([]provider.Message(nil), messages[start:]...) +} diff --git a/internal/context/types.go b/internal/context/types.go new file mode 100644 index 00000000..0c1e7a96 --- /dev/null +++ b/internal/context/types.go @@ -0,0 +1,24 @@ +package context + +import ( + "context" + + "neo-code/internal/provider" +) + +// Builder builds the provider-facing context for a single model round. +type Builder interface { + Build(ctx context.Context, input BuildInput) (BuildResult, error) +} + +// BuildInput contains the runtime state needed to assemble model context. +type BuildInput struct { + Messages []provider.Message + Workdir string +} + +// BuildResult is the provider-facing context produced for a single round. +type BuildResult struct { + SystemPrompt string + Messages []provider.Message +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 193e7295..22c7847f 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -8,12 +8,11 @@ import ( "time" "neo-code/internal/config" + agentcontext "neo-code/internal/context" "neo-code/internal/provider" "neo-code/internal/tools" ) -const maxContextTurns = 10 - type Runtime interface { Run(ctx context.Context, input UserInput) error CancelActiveRun() bool @@ -33,27 +32,38 @@ type ProviderFactory interface { } type Service struct { - configManager *config.Manager //配置管理器,提供当前选中的 provider、model、workdir 等配置读取能力 - sessionStore Store //会话持久化接口,负责保存和加载聊天会话 - toolRegistry *tools.Registry //工具注册表,维护所有工具的schema - providerFactory ProviderFactory //provider 工厂接口,根据配置动态创建具体的 provider 实例 - events chan RuntimeEvent //事件通道,Runtime 在运行过程中产生的所有事件都通过这个 channel 发送给 TUI 层消费和展示 - runMu sync.Mutex //运行互斥锁,保证同一时间只有一个 Run 在执行 - activeRunToken uint64 //当前活跃运行的令牌标识,用于标记正在执行的 Run 实例,配合 nextRunToken 实现新旧 Run 的安全切换 - nextRunToken uint64 //下一个运行令牌的递增计数器,每次启动新 Run 时递增并赋给 activeRunToken,用于区分不同 Run 的生命周期 - activeRunCancel context.CancelFunc //当前活跃 Run 的取消函数 + configManager *config.Manager + sessionStore Store + toolRegistry *tools.Registry + providerFactory ProviderFactory + contextBuilder agentcontext.Builder + events chan RuntimeEvent + runMu sync.Mutex + activeRunToken uint64 + nextRunToken uint64 + activeRunCancel context.CancelFunc } -func NewWithFactory(configManager *config.Manager, toolRegistry *tools.Registry, sessionStore Store, providerFactory ProviderFactory) *Service { +func NewWithFactory( + configManager *config.Manager, + toolRegistry *tools.Registry, + sessionStore Store, + providerFactory ProviderFactory, + contextBuilder agentcontext.Builder, +) *Service { if providerFactory == nil { providerFactory = provider.NewRegistry() } + if contextBuilder == nil { + contextBuilder = agentcontext.NewBuilder() + } return &Service{ configManager: configManager, sessionStore: sessionStore, toolRegistry: toolRegistry, providerFactory: providerFactory, + contextBuilder: contextBuilder, events: make(chan RuntimeEvent, 128), } } @@ -115,14 +125,22 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { return err } + builtContext, err := s.contextBuilder.Build(ctx, agentcontext.BuildInput{ + Messages: session.Messages, + Workdir: cfg.Workdir, + }) + if err != nil { + return s.handleRunError(ctx, input.RunID, session.ID, err) + } + streamEvents := make(chan provider.StreamEvent, 32) streamDone := make(chan struct{}) go s.forwardProviderEvents(ctx, input.RunID, session.ID, streamEvents, streamDone) resp, err := modelProvider.Chat(ctx, provider.ChatRequest{ Model: cfg.CurrentModel, - SystemPrompt: s.systemPrompt(), - Messages: s.trimMessages(session.Messages), + SystemPrompt: builtContext.SystemPrompt, + Messages: builtContext.Messages, Tools: s.toolRegistry.GetSpecs(), }, streamEvents) close(streamEvents) @@ -251,9 +269,6 @@ func (s *Service) loadOrCreateSession(ctx context.Context, sessionID string, tit return s.sessionStore.Load(ctx, sessionID) } -// emit 向事件通道发送事件。 -// 先尝试非阻塞发送,确保即使 context 已取消,只要通道有空间事件就能被投递; -// 仅在通道已满时才通过 ctx.Done() 退出,避免 goroutine 泄漏。 func (s *Service) emit(ctx context.Context, kind EventType, runID string, sessionID string, payload any) { evt := RuntimeEvent{ Type: kind, @@ -272,8 +287,6 @@ func (s *Service) emit(ctx context.Context, kind EventType, runID string, sessio } } -// forwardProviderEvents 将 provider 流式事件转发为 runtime 事件。 -// 使用 select 同时监听输入通道和 context 取消信号,确保 goroutine 不会因通道阻塞而泄漏。 func (s *Service) forwardProviderEvents(ctx context.Context, runID string, sessionID string, input <-chan provider.StreamEvent, done chan<- struct{}) { defer close(done) for { @@ -330,45 +343,3 @@ func (s *Service) handleRunError(ctx context.Context, runID string, sessionID st func (s *Service) isRunCanceled(err error) bool { return errors.Is(err, context.Canceled) } - -func (s *Service) trimMessages(messages []provider.Message) []provider.Message { - if len(messages) <= maxContextTurns { - return append([]provider.Message(nil), messages...) - } - - type span struct { - start int - end int - } - - spans := make([]span, 0, len(messages)) - for i := 0; i < len(messages); { - start := i - i++ - - if messages[start].Role == "assistant" && len(messages[start].ToolCalls) > 0 { - for i < len(messages) && messages[i].Role == "tool" { - i++ - } - } - - spans = append(spans, span{start: start, end: i}) - } - - if len(spans) <= maxContextTurns { - return append([]provider.Message(nil), messages...) - } - - start := spans[len(spans)-maxContextTurns].start - clipped := append([]provider.Message(nil), messages[start:]...) - return clipped -} - -func (s *Service) systemPrompt() string { - return `You are NeoCode, a local coding agent. - - Be concise and accurate. - Use tools when necessary. - When a tool fails, inspect the error and continue safely. - Stay within the workspace and avoid destructive behavior unless clearly requested.` -} diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 254a254c..c29b4510 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -9,6 +9,7 @@ import ( "testing" "neo-code/internal/config" + agentcontext "neo-code/internal/context" "neo-code/internal/provider" "neo-code/internal/provider/builtin" "neo-code/internal/tools" @@ -171,6 +172,26 @@ func (t *stubTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool }, t.err } +type stubContextBuilder struct { + buildFn func(ctx context.Context, input agentcontext.BuildInput) (agentcontext.BuildResult, error) + callCount int + lastInput agentcontext.BuildInput + builds []agentcontext.BuildInput +} + +func (b *stubContextBuilder) Build(ctx context.Context, input agentcontext.BuildInput) (agentcontext.BuildResult, error) { + b.callCount++ + b.lastInput = cloneBuildInput(input) + b.builds = append(b.builds, cloneBuildInput(input)) + if b.buildFn != nil { + return b.buildFn(ctx, input) + } + return agentcontext.BuildResult{ + SystemPrompt: "stub system prompt", + Messages: append([]provider.Message(nil), input.Messages...), + }, nil +} + func TestServiceRun(t *testing.T) { tests := []struct { name string @@ -178,6 +199,7 @@ func TestServiceRun(t *testing.T) { providerResponses []provider.ChatResponse providerStreams [][]provider.StreamEvent registerTool tools.Tool + contextBuilder agentcontext.Builder expectProviderCalls int expectToolCalls int expectMessageRoles []string @@ -202,6 +224,16 @@ func TestServiceRun(t *testing.T) { {Type: provider.StreamEventTextDelta, Text: "answer"}, }, }, + contextBuilder: &stubContextBuilder{ + buildFn: func(ctx context.Context, input agentcontext.BuildInput) (agentcontext.BuildResult, error) { + return agentcontext.BuildResult{ + SystemPrompt: "custom system prompt", + Messages: []provider.Message{ + {Role: "user", Content: "trimmed history"}, + }, + }, nil + }, + }, expectProviderCalls: 1, expectToolCalls: 0, expectMessageRoles: []string{"user", "assistant"}, @@ -214,8 +246,11 @@ func TestServiceRun(t *testing.T) { if len(scripted.requests[0].Tools) == 0 { t.Fatalf("expected tool specs to be forwarded") } - if scripted.requests[0].SystemPrompt == "" { - t.Fatalf("expected system prompt to be set") + if scripted.requests[0].SystemPrompt != "custom system prompt" { + t.Fatalf("expected system prompt from context builder, got %q", scripted.requests[0].SystemPrompt) + } + if len(scripted.requests[0].Messages) != 1 || scripted.requests[0].Messages[0].Content != "trimmed history" { + t.Fatalf("expected messages from context builder, got %+v", scripted.requests[0].Messages) } }, }, @@ -305,7 +340,7 @@ func TestServiceRun(t *testing.T) { } factory := &scriptedProviderFactory{provider: scripted} - service := NewWithFactory(manager, registry, store, factory) + service := NewWithFactory(manager, registry, store, factory, tt.contextBuilder) if err := service.Run(context.Background(), tt.input); err != nil { t.Fatalf("Run() error = %v", err) } @@ -338,114 +373,60 @@ func TestServiceRun(t *testing.T) { } } -func TestServiceTrimMessagesPreservesToolPairs(t *testing.T) { +func TestServiceRunDelegatesToContextBuilder(t *testing.T) { t.Parallel() manager := newRuntimeConfigManager(t) - service := NewWithFactory(manager, tools.NewRegistry(), newMemoryStore(), &scriptedProviderFactory{provider: &scriptedProvider{}}) + store := newMemoryStore() + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) - messages := make([]provider.Message, 0, maxContextTurns+4) - for i := 0; i < 8; i++ { - messages = append(messages, provider.Message{Role: "user", Content: fmt.Sprintf("u-%d", i)}) + builder := &stubContextBuilder{ + buildFn: func(ctx context.Context, input agentcontext.BuildInput) (agentcontext.BuildResult, error) { + return agentcontext.BuildResult{ + SystemPrompt: "delegated prompt", + Messages: []provider.Message{ + {Role: "user", Content: "delegated message"}, + }, + }, nil + }, } - messages = append(messages, - provider.Message{ - Role: "assistant", - ToolCalls: []provider.ToolCall{ - {ID: "call-1", Name: "filesystem_edit", Arguments: "{}"}, + + scripted := &scriptedProvider{ + responses: []provider.ChatResponse{ + { + Message: provider.Message{ + Role: "assistant", + Content: "done", + }, + FinishReason: "stop", }, }, - provider.Message{Role: "tool", ToolCallID: "call-1", Content: "tool-result"}, - provider.Message{Role: "assistant", Content: "after-tool"}, - provider.Message{Role: "user", Content: "latest"}, - ) + } - trimmed := service.trimMessages(messages) - if len(trimmed) > len(messages) { - t.Fatalf("trimmed messages should not grow") + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, builder) + input := UserInput{RunID: "run-context-builder", Content: "hello"} + if err := service.Run(context.Background(), input); err != nil { + t.Fatalf("Run() error = %v", err) } - foundAssistantToolCall := false - foundToolResult := false - for _, message := range trimmed { - if message.Role == "assistant" && len(message.ToolCalls) > 0 { - foundAssistantToolCall = true - } - if message.Role == "tool" && message.ToolCallID == "call-1" { - foundToolResult = true - } + if builder.callCount != 1 { + t.Fatalf("expected builder to be called once, got %d", builder.callCount) } - if foundAssistantToolCall != foundToolResult { - t.Fatalf("expected tool call and tool result to be preserved together, got %+v", trimmed) + if builder.lastInput.Workdir == "" { + t.Fatalf("expected workdir to be forwarded to builder") } -} - -func TestServiceTrimMessagesBoundaries(t *testing.T) { - t.Parallel() - - manager := newRuntimeConfigManager(t) - service := NewWithFactory(manager, tools.NewRegistry(), newMemoryStore(), &scriptedProviderFactory{provider: &scriptedProvider{}}) - - tests := []struct { - name string - input []provider.Message - wantLen int - assert func(t *testing.T, original []provider.Message, trimmed []provider.Message) - }{ - { - name: "within max turns returns full cloned slice", - input: []provider.Message{ - {Role: "user", Content: "one"}, - {Role: "assistant", Content: "two"}, - }, - wantLen: 2, - assert: func(t *testing.T, original []provider.Message, trimmed []provider.Message) { - t.Helper() - if &trimmed[0] == &original[0] { - t.Fatalf("expected trimmed slice to be cloned") - } - }, - }, - { - name: "long message list with limited spans keeps full history", - input: func() []provider.Message { - messages := make([]provider.Message, 0, maxContextTurns+3) - for i := 0; i < maxContextTurns-1; i++ { - messages = append(messages, provider.Message{Role: "user", Content: fmt.Sprintf("u-%d", i)}) - } - messages = append(messages, - provider.Message{ - Role: "assistant", - ToolCalls: []provider.ToolCall{ - {ID: "call-1", Name: "filesystem_edit", Arguments: "{}"}, - }, - }, - provider.Message{Role: "tool", ToolCallID: "call-1", Content: "tool-1"}, - provider.Message{Role: "tool", ToolCallID: "call-1", Content: "tool-2"}, - ) - return messages - }(), - wantLen: maxContextTurns + 2, - assert: func(t *testing.T, original []provider.Message, trimmed []provider.Message) { - t.Helper() - if len(trimmed) != len(original) { - t.Fatalf("expected full history to remain, got %d want %d", len(trimmed), len(original)) - } - }, - }, + if len(builder.lastInput.Messages) != 1 || builder.lastInput.Messages[0].Content != "hello" { + t.Fatalf("expected persisted session messages to be forwarded, got %+v", builder.lastInput.Messages) } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - trimmed := service.trimMessages(tt.input) - if len(trimmed) != tt.wantLen { - t.Fatalf("expected len %d, got %d", tt.wantLen, len(trimmed)) - } - if tt.assert != nil { - tt.assert(t, tt.input, trimmed) - } - }) + if len(scripted.requests) != 1 { + t.Fatalf("expected one provider request, got %d", len(scripted.requests)) + } + if scripted.requests[0].SystemPrompt != "delegated prompt" { + t.Fatalf("expected delegated prompt, got %q", scripted.requests[0].SystemPrompt) + } + if len(scripted.requests[0].Messages) != 1 || scripted.requests[0].Messages[0].Content != "delegated message" { + t.Fatalf("expected delegated messages, got %+v", scripted.requests[0].Messages) } } @@ -584,7 +565,7 @@ func TestServiceRunErrorPaths(t *testing.T) { err: tt.factoryErr, } - service := NewWithFactory(manager, registry, store, factory) + service := NewWithFactory(manager, registry, store, factory, nil) err := service.Run(context.Background(), tt.input) if tt.expectErr != "" { if err == nil || err.Error() != tt.expectErr && !containsError(err, tt.expectErr) { @@ -624,7 +605,7 @@ func TestServiceCancelActiveRun(t *testing.T) { }, } - service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}) + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) errCh := make(chan error, 1) input := UserInput{RunID: "run-cancel-active", Content: "hello"} @@ -667,7 +648,7 @@ func TestServiceRunCanceledByProvider(t *testing.T) { }, } - service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}) + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) ctx, cancel := context.WithCancel(context.Background()) errCh := make(chan error, 1) input := UserInput{RunID: "run-provider-cancel", Content: "hello"} @@ -712,7 +693,7 @@ func TestServiceRunPreservesProviderErrorAfterCancel(t *testing.T) { }, } - service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}) + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) ctx, cancel := context.WithCancel(context.Background()) errCh := make(chan error, 1) input := UserInput{RunID: "run-provider-error-after-cancel", Content: "hello"} @@ -770,7 +751,7 @@ func TestServiceRunCanceledDuringToolExecution(t *testing.T) { }, } - service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}) + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) ctx, cancel := context.WithCancel(context.Background()) errCh := make(chan error, 1) input := UserInput{RunID: "run-tool-cancel", Content: "edit file"} @@ -835,7 +816,7 @@ func TestServiceRunPreservesToolErrorAfterCancel(t *testing.T) { }, } - service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}) + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) ctx, cancel := context.WithCancel(context.Background()) errCh := make(chan error, 1) input := UserInput{RunID: "run-tool-error-after-cancel", Content: "edit file"} @@ -878,7 +859,7 @@ func TestServiceRunPreservesSessionSaveErrorAfterCancel(t *testing.T) { service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{ provider: &scriptedProvider{name: "unused"}, - }) + }, nil) ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -939,7 +920,7 @@ func TestServiceRunToolTimeoutIsNotCancellation(t *testing.T) { }, } - service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}) + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) input := UserInput{RunID: "run-tool-timeout", Content: "edit file"} if err := service.Run(context.Background(), input); err != nil { t.Fatalf("Run() error = %v", err) @@ -967,7 +948,7 @@ func TestServiceConstructorsAndDelegates(t *testing.T) { registry := tools.NewRegistry() registry.Register(&stubTool{name: "filesystem_read_file", content: "ok"}) - service := NewWithFactory(manager, registry, store, nil) + service := NewWithFactory(manager, registry, store, nil, nil) if service == nil { t.Fatalf("expected service") } @@ -1103,6 +1084,12 @@ func cloneChatRequest(req provider.ChatRequest) provider.ChatRequest { return cloned } +func cloneBuildInput(input agentcontext.BuildInput) agentcontext.BuildInput { + cloned := input + cloned.Messages = append([]provider.Message(nil), input.Messages...) + return cloned +} + func containsError(err error, target string) bool { return err != nil && strings.Contains(err.Error(), target) } From 9410a55b40b4a1d91d875aa31182d925f96a47a3 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 1 Apr 2026 16:32:15 +0800 Subject: [PATCH 2/3] refactor(provider): centralize message role constants --- AGENTS.md | 3 +++ agents.md | 3 +++ internal/context/trim.go | 4 ++-- internal/provider/openai/openai.go | 4 ++-- internal/provider/types.go | 7 +++++++ internal/runtime/runtime.go | 6 +++--- 6 files changed, 20 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1b227253..d80b22cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,10 @@ - 使用制表符缩进,尽量将单行控制在约 120 字符内。 - 核心抽象上的导出类型、函数、接口应补充清晰注释,尤其是 `provider`、`runtime`、`tools`。 - 避免硬编码路径、URL、模型名、超时、输出长度限制和环境差异项,优先通过配置、参数或具名常量注入。 +- 避免硬编码业务语义字符串或状态值(例如消息角色、事件类型、协议字段值);如需复用,优先收敛到共享常量、类型或统一定义处。 - 优先写清晰、可替换的实现,不要为了“未来可能需要”提前引入复杂泛化。 +- 不要擅自删除、改写或遗漏原有注释;若注释需要调整,应在理解原意后做等价更新,而不是直接移除。 +- 如果文件内容疑似乱码或编码异常,先尝试用其他编码方式查看和确认原文,再决定是否修改;未确认前不要擅自删除或重写相关内容。 ## 7. 配置与安全规则 - 配置文件默认路径为 `~/.neocode/config.yaml`。 diff --git a/agents.md b/agents.md index 1b227253..d80b22cd 100644 --- a/agents.md +++ b/agents.md @@ -76,7 +76,10 @@ - 使用制表符缩进,尽量将单行控制在约 120 字符内。 - 核心抽象上的导出类型、函数、接口应补充清晰注释,尤其是 `provider`、`runtime`、`tools`。 - 避免硬编码路径、URL、模型名、超时、输出长度限制和环境差异项,优先通过配置、参数或具名常量注入。 +- 避免硬编码业务语义字符串或状态值(例如消息角色、事件类型、协议字段值);如需复用,优先收敛到共享常量、类型或统一定义处。 - 优先写清晰、可替换的实现,不要为了“未来可能需要”提前引入复杂泛化。 +- 不要擅自删除、改写或遗漏原有注释;若注释需要调整,应在理解原意后做等价更新,而不是直接移除。 +- 如果文件内容疑似乱码或编码异常,先尝试用其他编码方式查看和确认原文,再决定是否修改;未确认前不要擅自删除或重写相关内容。 ## 7. 配置与安全规则 - 配置文件默认路径为 `~/.neocode/config.yaml`。 diff --git a/internal/context/trim.go b/internal/context/trim.go index aa403699..8947e7f3 100644 --- a/internal/context/trim.go +++ b/internal/context/trim.go @@ -19,8 +19,8 @@ func trimMessages(messages []provider.Message) []provider.Message { start := i i++ - if messages[start].Role == "assistant" && len(messages[start].ToolCalls) > 0 { - for i < len(messages) && messages[i].Role == "tool" { + if messages[start].Role == provider.RoleAssistant && len(messages[start].ToolCalls) > 0 { + for i < len(messages) && messages[i].Role == provider.RoleTool { i++ } } diff --git a/internal/provider/openai/openai.go b/internal/provider/openai/openai.go index f4056bc2..a00e8204 100644 --- a/internal/provider/openai/openai.go +++ b/internal/provider/openai/openai.go @@ -106,7 +106,7 @@ func (p *Provider) buildRequest(req domain.ChatRequest) (chatCompletionRequest, if strings.TrimSpace(req.SystemPrompt) != "" { payload.Messages = append(payload.Messages, openAIMessage{ - Role: "system", + Role: domain.RoleSystem, Content: req.SystemPrompt, }) } @@ -342,7 +342,7 @@ func finalizeResponse(content string, toolCalls map[int]*domain.ToolCall, finish sort.Ints(ordered) message := domain.Message{ - Role: "assistant", + Role: domain.RoleAssistant, Content: content, } diff --git a/internal/provider/types.go b/internal/provider/types.go index d03ff5d0..2a08b2f0 100644 --- a/internal/provider/types.go +++ b/internal/provider/types.go @@ -4,6 +4,13 @@ import ( "errors" ) +const ( + RoleSystem = "system" + RoleUser = "user" + RoleAssistant = "assistant" + RoleTool = "tool" +) + type Message struct { Role string `json:"role"` Content string `json:"content"` diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 22c7847f..4a486a6b 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -87,7 +87,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { } userMessage := provider.Message{ - Role: "user", + Role: provider.RoleUser, Content: input.Content, } session.Messages = append(session.Messages, userMessage) @@ -154,7 +154,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { assistant := resp.Message if strings.TrimSpace(assistant.Role) == "" { - assistant.Role = "assistant" + assistant.Role = provider.RoleAssistant } if strings.TrimSpace(assistant.Content) != "" || len(assistant.ToolCalls) > 0 { @@ -205,7 +205,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { } toolMessage := provider.Message{ - Role: "tool", + Role: provider.RoleTool, Content: result.Content, ToolCallID: call.ID, IsError: result.IsError, From ef85b5fd68d7bf7d1f12f2c2782f91293c1cfc73 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 1 Apr 2026 16:47:45 +0800 Subject: [PATCH 3/3] docs(runtime): restore dropped runtime comments --- internal/runtime/runtime.go | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 4a486a6b..48d685b0 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -32,16 +32,16 @@ type ProviderFactory interface { } type Service struct { - configManager *config.Manager - sessionStore Store - toolRegistry *tools.Registry - providerFactory ProviderFactory - contextBuilder agentcontext.Builder - events chan RuntimeEvent - runMu sync.Mutex - activeRunToken uint64 - nextRunToken uint64 - activeRunCancel context.CancelFunc + configManager *config.Manager // 配置管理器,提供当前选中的 provider、model、workdir 等配置读取能力。 + sessionStore Store // 会话持久化接口,负责保存和加载聊天会话。 + toolRegistry *tools.Registry // 工具注册表,维护所有工具的 schema。 + providerFactory ProviderFactory // Provider 工厂接口,根据配置动态创建具体的 provider 实例。 + contextBuilder agentcontext.Builder // 上下文构建器,负责组装 system prompt 与本轮发给模型的消息上下文。 + events chan RuntimeEvent // 事件通道,Runtime 在运行过程中产生的事件都通过该通道发送给 TUI 层消费和展示。 + runMu sync.Mutex // 运行互斥锁,保证同一时间只有一个 Run 在执行。 + activeRunToken uint64 // 当前活跃运行的令牌标识,用于标记正在执行的 Run 实例。 + nextRunToken uint64 // 下一个运行令牌的递增计数器,用于区分不同 Run 的生命周期。 + activeRunCancel context.CancelFunc // 当前活跃 Run 的取消函数。 } func NewWithFactory( @@ -269,6 +269,9 @@ func (s *Service) loadOrCreateSession(ctx context.Context, sessionID string, tit return s.sessionStore.Load(ctx, sessionID) } +// emit 向事件通道发送事件。 +// 先尝试非阻塞发送,确保即使 context 已取消,只要通道有空间事件就能被投递; +// 仅在通道已满时才通过 ctx.Done() 退出,避免 goroutine 泄漏。 func (s *Service) emit(ctx context.Context, kind EventType, runID string, sessionID string, payload any) { evt := RuntimeEvent{ Type: kind, @@ -287,6 +290,8 @@ func (s *Service) emit(ctx context.Context, kind EventType, runID string, sessio } } +// forwardProviderEvents 将 provider 流式事件转发为 runtime 事件。 +// 使用 select 同时监听输入通道和 context 取消信号,确保 goroutine 不会因通道阻塞而泄漏。 func (s *Service) forwardProviderEvents(ctx context.Context, runID string, sessionID string, input <-chan provider.StreamEvent, done chan<- struct{}) { defer close(done) for {