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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@
- 使用制表符缩进,尽量将单行控制在约 120 字符内。
- 核心抽象上的导出类型、函数、接口应补充清晰注释,尤其是 `provider`、`runtime`、`tools`。
- 避免硬编码路径、URL、模型名、超时、输出长度限制和环境差异项,优先通过配置、参数或具名常量注入。
- 避免硬编码业务语义字符串或状态值(例如消息角色、事件类型、协议字段值);如需复用,优先收敛到共享常量、类型或统一定义处。
- 优先写清晰、可替换的实现,不要为了“未来可能需要”提前引入复杂泛化。
- 不要擅自删除、改写或遗漏原有注释;若注释需要调整,应在理解原意后做等价更新,而不是直接移除。
- 如果文件内容疑似乱码或编码异常,先尝试用其他编码方式查看和确认原文,再决定是否修改;未确认前不要擅自删除或重写相关内容。

## 7. 配置与安全规则
- 配置文件默认路径为 `~/.neocode/config.yaml`。
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ go run ./cmd/neocode
核心模块职责:

- **`internal/config`** — 配置管理、环境变量、YAML 加载
- **`internal/context`** — system prompt、消息裁剪与上下文构建
- **`internal/provider`** — LLM 提供商抽象,抹平厂商差异
- **`internal/runtime`** — ReAct 主循环、事件流、会话管理
- **`internal/tools`** — 工具注册表与具体工具实现
Expand All @@ -84,6 +85,7 @@ go run ./cmd/neocode
├── internal
│ ├── app # 应用装配
│ ├── config # 配置管理
│ ├── context # 上下文构建
│ ├── provider # Provider 抽象与实现
│ ├── runtime # ReAct 循环与事件流
│ ├── tools # 工具系统
Expand Down
3 changes: 3 additions & 0 deletions agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@
- 使用制表符缩进,尽量将单行控制在约 120 字符内。
- 核心抽象上的导出类型、函数、接口应补充清晰注释,尤其是 `provider`、`runtime`、`tools`。
- 避免硬编码路径、URL、模型名、超时、输出长度限制和环境差异项,优先通过配置、参数或具名常量注入。
- 避免硬编码业务语义字符串或状态值(例如消息角色、事件类型、协议字段值);如需复用,优先收敛到共享常量、类型或统一定义处。
- 优先写清晰、可替换的实现,不要为了“未来可能需要”提前引入复杂泛化。
- 不要擅自删除、改写或遗漏原有注释;若注释需要调整,应在理解原意后做等价更新,而不是直接移除。
- 如果文件内容疑似乱码或编码异常,先尝试用其他编码方式查看和确认原文,再决定是否修改;未确认前不要擅自删除或重写相关内容。

## 7. 配置与安全规则
- 配置文件默认路径为 `~/.neocode/config.yaml`。
Expand Down
16 changes: 11 additions & 5 deletions docs/runtime-provider-event-flow.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,35 @@
# Runtime 与 Provider 事件流设计

## Runtime 事件类型

当前 runtime 对外暴露一组小而稳定的事件:

- `agent_chunk`
- `agent_done`
- `tool_start`
- `tool_result`
- `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 完整回复后保存
- 每个工具结果完成后保存
Expand Down
3 changes: 2 additions & 1 deletion internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions internal/context/builder.go
Original file line number Diff line number Diff line change
@@ -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
}
185 changes: 185 additions & 0 deletions internal/context/builder_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
10 changes: 10 additions & 0 deletions internal/context/prompt.go
Comment thread
wynxing marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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.`
}
37 changes: 37 additions & 0 deletions internal/context/trim.go
Original file line number Diff line number Diff line change
@@ -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 == provider.RoleAssistant && len(messages[start].ToolCalls) > 0 {
for i < len(messages) && messages[i].Role == provider.RoleTool {
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:]...)
}
24 changes: 24 additions & 0 deletions internal/context/types.go
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 2 additions & 2 deletions internal/provider/openai/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
Expand Down Expand Up @@ -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,
}

Expand Down
Loading
Loading