From 0779e83d5ee47826d1bb800116653836011abcf0 Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Wed, 25 Mar 2026 17:45:02 +0800 Subject: [PATCH 1/9] =?UTF-8?q?refactor:=20=E7=BB=9F=E4=B8=80=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E4=BF=A1=E6=81=AF=E6=9D=A5=E6=BA=90=E5=B9=B6=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=20schema=20=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 + cmd/server/main.go | 9 +- configs/persona.txt | 83 ++----- configs/persona.txt.example | 33 +-- docs/TUI_REFINED_ARCHITECTURE.md | 7 +- internal/server/domain/tool.go | 18 +- internal/server/infra/tools/bash.go | 10 +- internal/server/infra/tools/edit.go | 2 +- internal/server/infra/tools/grep.go | 2 +- internal/server/infra/tools/list.go | 2 +- internal/server/infra/tools/read.go | 4 +- internal/server/infra/tools/schema_prompt.go | 43 ++++ .../server/infra/tools/schema_prompt_test.go | 75 ++++++ internal/server/infra/tools/todo.go | 6 +- internal/server/service/chat_service.go | 50 ++-- internal/server/service/chat_service_test.go | 219 ++++++++++++++++++ internal/tui/services/api_client.go | 6 +- 17 files changed, 456 insertions(+), 119 deletions(-) create mode 100644 internal/server/infra/tools/schema_prompt.go create mode 100644 internal/server/infra/tools/schema_prompt_test.go create mode 100644 internal/server/service/chat_service_test.go diff --git a/README.md b/README.md index 51867cb2..e32e5861 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,12 @@ persona: - `history.resume_last_session`:启动时是否展示恢复摘要 - `persona.file_path`:启动时加载的人设文件 +## Tool Schema 维护说明 + +- `configs/persona.txt` 现在只负责行为约束和回复风格,不再维护任何具体工具参数说明。 +- 模型可见的工具参数、必填项、默认值和枚举约束,统一来源于 `internal/server/infra/tools/*.go` 中各工具的 `Definition()`。 +- 聊天服务会在运行时把渲染后的 tool schema 注入到 system context,因此更新 schema 定义后,模型上下文会自动同步。 + ## Memory 设计 当前 memory 使用纯结构化规则召回,不使用 embedding 或向量相似度。系统会将长期结构化记忆写入 `memory.storage_path`,并在当前进程内维护 session memory。主要包括: diff --git a/cmd/server/main.go b/cmd/server/main.go index 18290079..b67a0883 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -51,6 +51,7 @@ func main() { todoRepo := repository.NewInMemoryTodoRepository() todoSvc := service.NewTodoService(todoRepo) + tools.GlobalRegistry.Register(tools.NewTodoTool(todoSvc)) chatProvider, err := provider.NewChatProvider(cfg.AI.Model) if err != nil { @@ -58,7 +59,13 @@ func main() { return } - chatGateway := service.NewChatService(memorySvc, workingSvc, todoSvc, roleSvc, chatProvider) + schemaPrompt, err := tools.GlobalSchemaPrompt() + if err != nil { + fmt.Printf("生成工具 schema 上下文失败:%v\n", err) + return + } + + chatGateway := service.NewChatService(memorySvc, workingSvc, todoSvc, roleSvc, chatProvider, schemaPrompt) fmt.Printf("服务器已初始化并加载服务: %+v\n", chatGateway) fmt.Println("注意:这是一个占位符。实际的服务器实现将在此处进行.") } diff --git a/configs/persona.txt b/configs/persona.txt index cfd90216..8e10391d 100644 --- a/configs/persona.txt +++ b/configs/persona.txt @@ -1,61 +1,22 @@ -你是 NeoCode,一个专业、可靠、简洁的 AI 编程助手。 - -要求: -- 优先给出可执行、可落地的工程方案 -- 回答简洁清晰,必要时给出步骤 -- 对不确定的内容明确说明假设 -- 默认使用中文回答 - -安全与工具执行要求(必须遵守): -- 对可能有副作用的操作(尤其是 bash、写文件、网络请求),先进行风险判断,再执行。 -- 当命中安全策略为 deny 时,必须明确拒绝并解释原因,不得尝试绕过。 -- 当命中安全策略为 ask 时,先向用户确认,再继续执行。 -- 执行 bash 前必须说明命令目的、影响范围与关键参数;优先使用只读、可回滚、最小权限命令。 -- 严禁执行高危破坏性命令(如无边界删除、系统级破坏、恶意下载执行等)。 -- 涉及路径操作时,默认限定在当前工作区内,不对工作区外路径进行读写。 - -你可以调用edit,grep,list,read,write,bash工具,规范和opencode保持一致 -你可以调用edit,grep,list,read,write,“todo”工具,规范和opencode保持一致 - -当需要管理任务清单以追踪复杂任务进度时,调用 todo 工具。 -操作类型(action): -- add: 添加新任务。参数:content(任务内容), priority(优先级:high/medium/low) -- update: 更新任务状态。参数:id(任务ID,如todo-1), status(pending/in_progress/completed) -- list: 列出所有任务。 -- remove: 移除特定任务。参数:id -- clear: 清空所有任务。 - -当需要查看指定目录下的文件 / 目录结构时,调用 list 工具。 -必填参数:path(目标目录路径,如/home/project); -可选参数:recursive(是否递归列出子目录,布尔值,默认 false) - -当需要获取指定文件的完整内容(如分析代码、验证配置)时,调用 read 工具。 -必填参数:filePath(目标文件完整路径,如/home/project/main.py) -可选参数:encoding(文件编码,默认 utf-8) - -当需要在指定路径下匹配关键词 / 正则表达式、定位目标文本时,调用 grep 工具。 -必填参数:pattern(匹配模式 / 正则,如error:.*)、path(目标文件 / 目录路径) -可选参数:ignore_case(是否忽略大小写,布尔值,默认 false),recursive(是否递归目录,布尔值,默认 true) - - -当需要新建文件或向文件写入内容(非增量修改)时,调用 write 工具。 -必填参数:filePath(目标文件路径)、content(待写入的文本内容) -可选参数:overwrite(是否覆盖原有文件,布尔值,默认 false) - -当需要精准替换文件中的指定文本片段时,调用 edit 工具。 -必填参数:filePath(目标文件路径)old_str(待替换的旧文本)、new_str(替换后的新文本); -可选参数:backup(是否创建文件备份,布尔值,默认 true) - -当需要在工作区目录内执行任意终端命令、脚本、系统操作时,调用 bash 工具。 -必填参数:command(待执行的 bash 命令字符串,如 grep 'error' ./log) -可选参数:workdir(命令执行目录,字符串,默认工作区根目录),timeout(命令超时时间,整数,单位毫秒,默认 120000),description(命令用途说明,字符串,默认空) - -你必须严格按照以下JSON格式返回工具调用指令,不要返回其他内容: -{ - "tool": "工具名称(如list/read/bash)", - "params": {"参数名": "参数值"}, - "thought": "你调用该工具的原因" -} - -调用工具时,你只能严格遵守json格式,不能输出多余字符,一次只能调用一个工具 -输出json完成后,主动终止对话,等待工具响应 \ No newline at end of file +You are NeoCode, a professional, reliable, and concise AI coding assistant. + +Behavior: +- Prefer concrete, executable engineering solutions. +- Keep answers clear and concise, and provide steps only when they help. +- State assumptions explicitly when information is uncertain. +- Reply in Chinese by default unless the user asks for another language. + +Safety and tool use: +- Treat the injected tool schema as the single source of truth for tool names, parameter names, parameter types, required fields, default values, and allowed values. +- Do not restate, duplicate, or invent tool parameters outside the schema. +- Evaluate risk before using tools with side effects, especially bash, file writes, and network requests. +- If the security policy returns deny, refuse clearly and explain why. +- If the security policy returns ask, ask the user for approval before continuing. +- Before running bash, explain the goal, scope, and key impact. Prefer read-only, reversible, least-privilege commands. +- Never run destructive or unsafe commands such as unbounded deletion, system damage, or malicious download-and-execute flows. +- Keep file operations inside the current workspace unless the user explicitly requests otherwise and the policy allows it. + +Tool call protocol: +- When a tool is needed, emit exactly one JSON object in the form {"tool":"","params":{...}}. +- Do not output any extra text before or after a tool call. +- After emitting the JSON tool call, stop and wait for the tool result. diff --git a/configs/persona.txt.example b/configs/persona.txt.example index 0d0b2141..8e10391d 100644 --- a/configs/persona.txt.example +++ b/configs/persona.txt.example @@ -1,15 +1,22 @@ -你是 NeoCode,一个专业、可靠、简洁的 AI 编程助手。 +You are NeoCode, a professional, reliable, and concise AI coding assistant. -要求: -- 优先给出可执行、可落地的工程方案 -- 回答简洁清晰,必要时给出步骤 -- 对不确定的内容明确说明假设 -- 默认使用中文回答 +Behavior: +- Prefer concrete, executable engineering solutions. +- Keep answers clear and concise, and provide steps only when they help. +- State assumptions explicitly when information is uncertain. +- Reply in Chinese by default unless the user asks for another language. -安全与工具执行要求(必须遵守): -- 对可能有副作用的操作(尤其是 bash、写文件、网络请求),先进行风险判断,再执行。 -- 当命中安全策略为 deny 时,必须明确拒绝并解释原因,不得尝试绕过。 -- 当命中安全策略为 ask 时,先向用户确认,再继续执行。 -- 执行 bash 前必须说明命令目的、影响范围与关键参数;优先使用只读、可回滚、最小权限命令。 -- 严禁执行高危破坏性命令(如无边界删除、系统级破坏、恶意下载执行等)。 -- 涉及路径操作时,默认限定在当前工作区内,不对工作区外路径进行读写。 +Safety and tool use: +- Treat the injected tool schema as the single source of truth for tool names, parameter names, parameter types, required fields, default values, and allowed values. +- Do not restate, duplicate, or invent tool parameters outside the schema. +- Evaluate risk before using tools with side effects, especially bash, file writes, and network requests. +- If the security policy returns deny, refuse clearly and explain why. +- If the security policy returns ask, ask the user for approval before continuing. +- Before running bash, explain the goal, scope, and key impact. Prefer read-only, reversible, least-privilege commands. +- Never run destructive or unsafe commands such as unbounded deletion, system damage, or malicious download-and-execute flows. +- Keep file operations inside the current workspace unless the user explicitly requests otherwise and the policy allows it. + +Tool call protocol: +- When a tool is needed, emit exactly one JSON object in the form {"tool":"","params":{...}}. +- Do not output any extra text before or after a tool call. +- After emitting the JSON tool call, stop and wait for the tool result. diff --git a/docs/TUI_REFINED_ARCHITECTURE.md b/docs/TUI_REFINED_ARCHITECTURE.md index 7363b3be..d6c1c99d 100644 --- a/docs/TUI_REFINED_ARCHITECTURE.md +++ b/docs/TUI_REFINED_ARCHITECTURE.md @@ -61,9 +61,10 @@ 1. 用户在输入框编辑内容,`core/update.go` 处理按键并更新 `textarea` 状态。 2. 按下 `F5` 或 `F8` 后,`handleSubmit()` 将用户消息写入 `chat_state`,然后触发 `streamResponse(...)`。 3. `services.ChatClient.Chat(...)` 启动本地聊天服务,流式返回模型输出。 -4. `StreamChunkMsg` 持续追加 assistant 内容;`StreamDoneMsg` 在流结束时检查最后一条 assistant 消息是否是工具调用 JSON。 -5. 若检测到 `{"tool":"...","params":{...}}`,TUI 会通过 `services.ExecuteToolCall(...)` 执行工具,并把工具结果重新注入为 system 上下文。 -6. 模型基于新的上下文继续生成,直到得到最终自然语言回复。 +4. 聊天服务会把角色提示、记忆上下文、任务上下文,以及由 `internal/server/infra/tools/*.go` 的 `Definition()` 渲染出的 tool schema 一并注入到 system context。 +5. `StreamChunkMsg` 持续追加 assistant 内容;`StreamDoneMsg` 在流结束时检查最后一条 assistant 消息是否是工具调用 JSON。 +6. 若检测到 `{"tool":"...","params":{...}}`,TUI 会通过 `services.ExecuteToolCall(...)` 执行工具,并把工具结果重新注入为 system 上下文。 +7. 模型基于新的上下文继续生成,直到得到最终自然语言回复。 --- diff --git a/internal/server/domain/tool.go b/internal/server/domain/tool.go index a94e34b2..c7e0a51b 100644 --- a/internal/server/domain/tool.go +++ b/internal/server/domain/tool.go @@ -5,7 +5,7 @@ import ( "strings" ) -// ToolCall 表示工具调用请求。 +// ToolCall represents a tool invocation request. type ToolCall struct { Tool string `json:"tool"` Params map[string]interface{} `json:"params"` @@ -42,7 +42,7 @@ type Tool interface { Run(params map[string]interface{}) *ToolResult } -// ToolResult 表示执行工具的结果。 +// ToolResult represents the result of a tool execution. type ToolResult struct { ToolName string `json:"tool"` Success bool `json:"success"` @@ -51,19 +51,21 @@ type ToolResult struct { Metadata map[string]interface{} `json:"metadata,omitempty"` } -// ToolDefinition 描述工具的定义,包括名称、描述和参数规范。 +// ToolDefinition describes a tool and its parameter schema. type ToolDefinition struct { Name string `json:"name"` Description string `json:"description"` Parameters []ToolParamSpec `json:"parameters"` } -// ToolParamSpec 描述工具的单个参数规范。 +// ToolParamSpec describes a single tool parameter. type ToolParamSpec struct { - Name string `json:"name"` // 参数名称 - Type string `json:"type"` // 参数类型(string, integer, boolean 等) - Required bool `json:"required"` // 是否必需 - Description string `json:"description"` // 参数描述 + Name string `json:"name"` + Type string `json:"type"` + Required bool `json:"required"` + Description string `json:"description"` + DefaultValue interface{} `json:"default,omitempty"` + Enum []string `json:"enum,omitempty"` } func (tr *ToolResult) MarshalJSON() ([]byte, error) { diff --git a/internal/server/infra/tools/bash.go b/internal/server/infra/tools/bash.go index ad589cc8..4f0aff73 100644 --- a/internal/server/infra/tools/bash.go +++ b/internal/server/infra/tools/bash.go @@ -18,8 +18,8 @@ func (b *BashTool) Definition() ToolDefinition { Description: "Execute a bash command in the workspace. Supports optional workdir and timeout, default is 120000ms.", Parameters: []ToolParamSpec{ {Name: "command", Type: "string", Required: true, Description: "The bash command to execute."}, - {Name: "workdir", Type: "string", Description: "Directory within the workspace to execute the command, defaults to workspace root."}, - {Name: "timeout", Type: "integer", Description: "Command timeout in milliseconds, default 120000."}, + {Name: "workdir", Type: "string", Description: "Directory within the workspace to execute the command. Defaults to the workspace root.", DefaultValue: "."}, + {Name: "timeout", Type: "integer", Description: "Command timeout in milliseconds.", DefaultValue: 120000}, {Name: "description", Type: "string", Description: "A brief explanation of the command purpose for logs and auditing."}, }, } @@ -52,7 +52,11 @@ func (b *BashTool) Run(params map[string]interface{}) *ToolResult { pathErr.ToolName = b.Definition().Name return pathErr } - description, _ := optionalString(params, "description", "") + description, errRes := optionalString(params, "description", "") + if errRes != nil { + errRes.ToolName = b.Definition().Name + return errRes + } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMs)*time.Millisecond) defer cancel() diff --git a/internal/server/infra/tools/edit.go b/internal/server/infra/tools/edit.go index 432ce942..eece92d7 100644 --- a/internal/server/infra/tools/edit.go +++ b/internal/server/infra/tools/edit.go @@ -17,7 +17,7 @@ func (e *EditTool) Definition() ToolDefinition { {Name: "filePath", Type: "string", Required: true, Description: "Path to the file to modify within the workspace."}, {Name: "oldString", Type: "string", Required: true, Description: "The original text to replace, must match file content exactly."}, {Name: "newString", Type: "string", Required: true, Description: "The new text to replace with, must be different from oldString."}, - {Name: "replaceAll", Type: "boolean", Description: "Whether to replace all occurrences, default false."}, + {Name: "replaceAll", Type: "boolean", Description: "Whether to replace all occurrences.", DefaultValue: false}, }, } } diff --git a/internal/server/infra/tools/grep.go b/internal/server/infra/tools/grep.go index 751642f5..0e1c5868 100644 --- a/internal/server/infra/tools/grep.go +++ b/internal/server/infra/tools/grep.go @@ -17,7 +17,7 @@ func (g *GrepTool) Definition() ToolDefinition { Description: "Recursively search for file content in the workspace using regular expressions and return all matches with file, line number, and text.", Parameters: []ToolParamSpec{ {Name: "pattern", Type: "string", Required: true, Description: "The regular expression to search for."}, - {Name: "path", Type: "string", Description: "Search root directory within the workspace, defaults to workspace root."}, + {Name: "path", Type: "string", Description: "Search root directory within the workspace. Defaults to the workspace root.", DefaultValue: "."}, {Name: "include", Type: "string", Description: "Optional filename glob filter, e.g., '*.go'."}, }, } diff --git a/internal/server/infra/tools/list.go b/internal/server/infra/tools/list.go index f4497a6d..f851ed34 100644 --- a/internal/server/infra/tools/list.go +++ b/internal/server/infra/tools/list.go @@ -12,7 +12,7 @@ func (l *ListTool) Definition() ToolDefinition { return ToolDefinition{ Name: "list", Description: "List directory contents in the workspace. One entry per line, subdirectories suffixed with '/'.", - Parameters: []ToolParamSpec{{Name: "path", Type: "string", Description: "Directory within the workspace to list, defaults to workspace root."}}, + Parameters: []ToolParamSpec{{Name: "path", Type: "string", Description: "Directory within the workspace to list. Defaults to the workspace root.", DefaultValue: "."}}, } } diff --git a/internal/server/infra/tools/read.go b/internal/server/infra/tools/read.go index 7e8e6b6e..2da2295c 100644 --- a/internal/server/infra/tools/read.go +++ b/internal/server/infra/tools/read.go @@ -15,8 +15,8 @@ func (r *ReadTool) Definition() ToolDefinition { Description: "Read a file or directory in the workspace. Supports offset/limit pagination for files, returns directory entries for directories.", Parameters: []ToolParamSpec{ {Name: "filePath", Type: "string", Required: true, Description: "Path to the target file or directory in the workspace. Supports relative paths."}, - {Name: "offset", Type: "integer", Description: "Starting line number for reading a file, 1-based, default 1."}, - {Name: "limit", Type: "integer", Description: "Maximum number of lines to return for a file, default 2000."}, + {Name: "offset", Type: "integer", Description: "Starting line number for reading a file, 1-based.", DefaultValue: 1}, + {Name: "limit", Type: "integer", Description: "Maximum number of lines to return for a file.", DefaultValue: 2000}, }, } } diff --git a/internal/server/infra/tools/schema_prompt.go b/internal/server/infra/tools/schema_prompt.go new file mode 100644 index 00000000..d835c7a6 --- /dev/null +++ b/internal/server/infra/tools/schema_prompt.go @@ -0,0 +1,43 @@ +package tools + +import ( + "encoding/json" + "fmt" + "strings" +) + +// GlobalSchemaPrompt renders the currently registered tool schemas as an +// English system-context block for the model. +func GlobalSchemaPrompt() (string, error) { + return BuildSchemaPrompt(GlobalRegistry.ListDefinitions()) +} + +// BuildSchemaPrompt converts tool definitions into a stable, machine-readable +// prompt block so the model can treat schema definitions as the single source +// of truth for tool calling. +func BuildSchemaPrompt(defs []ToolDefinition) (string, error) { + if len(defs) == 0 { + return "", nil + } + + payload := struct { + Tools []ToolDefinition `json:"tools"` + }{ + Tools: defs, + } + + encoded, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return "", fmt.Errorf("marshal tool schema prompt: %w", err) + } + + parts := []string{ + "[TOOL_SCHEMAS]", + "Use these tool schemas as the single source of truth for tool names, parameter names, parameter types, required fields, default values, and enumerated choices.", + "When you need to call a tool, emit exactly one JSON object in the form {\"tool\":\"\",\"params\":{...}}.", + "Do not invent tool names or parameters. Omit optional parameters when they are unnecessary.", + string(encoded), + "[/TOOL_SCHEMAS]", + } + return strings.Join(parts, "\n"), nil +} diff --git a/internal/server/infra/tools/schema_prompt_test.go b/internal/server/infra/tools/schema_prompt_test.go new file mode 100644 index 00000000..492b6900 --- /dev/null +++ b/internal/server/infra/tools/schema_prompt_test.go @@ -0,0 +1,75 @@ +package tools + +import ( + "strings" + "testing" +) + +func TestBuildSchemaPromptIncludesDefaultsAndEnums(t *testing.T) { + prompt, err := BuildSchemaPrompt([]ToolDefinition{ + { + Name: "todo", + Description: "Manage todos.", + Parameters: []ToolParamSpec{ + {Name: "action", Type: "string", Required: true, Enum: []string{"add", "list"}}, + {Name: "priority", Type: "string", DefaultValue: "medium"}, + {Name: "timeout", Type: "integer", DefaultValue: 120000}, + }, + }, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !strings.Contains(prompt, "[TOOL_SCHEMAS]") { + t.Fatalf("expected tool schema marker, got %q", prompt) + } + if !strings.Contains(prompt, `"name": "todo"`) { + t.Fatalf("expected tool name in prompt, got %q", prompt) + } + if !strings.Contains(prompt, `"default": "medium"`) { + t.Fatalf("expected string default in prompt, got %q", prompt) + } + if !strings.Contains(prompt, `"default": 120000`) { + t.Fatalf("expected integer default in prompt, got %q", prompt) + } + if !strings.Contains(prompt, `"enum": [`) || !strings.Contains(prompt, `"add"`) { + t.Fatalf("expected enum values in prompt, got %q", prompt) + } +} + +func TestBuildSchemaPromptHandlesEmptyDefinitions(t *testing.T) { + prompt, err := BuildSchemaPrompt(nil) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if prompt != "" { + t.Fatalf("expected empty prompt for empty schema, got %q", prompt) + } +} + +func TestToolDefinitionsExposeStructuredDefaults(t *testing.T) { + defs := NewToolRegistry().ListDefinitions() + var foundBash bool + + for _, def := range defs { + if def.Name == "bash" { + foundBash = true + var timeoutFound bool + for _, param := range def.Parameters { + if param.Name == "timeout" { + timeoutFound = true + if param.DefaultValue != 120000 { + t.Fatalf("expected bash timeout default 120000, got %#v", param.DefaultValue) + } + } + } + if !timeoutFound { + t.Fatal("expected bash timeout parameter") + } + } + } + + if !foundBash { + t.Fatal("expected bash definition to be registered") + } +} diff --git a/internal/server/infra/tools/todo.go b/internal/server/infra/tools/todo.go index 1dd316be..5a37f2b0 100644 --- a/internal/server/infra/tools/todo.go +++ b/internal/server/infra/tools/todo.go @@ -21,11 +21,11 @@ func (t *TodoTool) Definition() ToolDefinition { Name: "todo", Description: "Manage an explicit todo list: add tasks, update status, list items, remove items, or clear the list.", Parameters: []ToolParamSpec{ - {Name: "action", Type: "string", Required: true, Description: "Action type: add, update, list, remove, clear."}, + {Name: "action", Type: "string", Required: true, Description: "Action type.", Enum: []string{"add", "update", "list", "remove", "clear"}}, {Name: "content", Type: "string", Description: "Task content, used by add."}, - {Name: "priority", Type: "string", Description: "Task priority: high, medium, low. Used by add, default is medium."}, + {Name: "priority", Type: "string", Description: "Task priority used by add.", DefaultValue: "medium", Enum: []string{"high", "medium", "low"}}, {Name: "id", Type: "string", Description: "Task id, used by update and remove."}, - {Name: "status", Type: "string", Description: "Task status: pending, in_progress, completed. Used by update."}, + {Name: "status", Type: "string", Description: "Task status used by update.", Enum: []string{"pending", "in_progress", "completed"}}, }, } } diff --git a/internal/server/service/chat_service.go b/internal/server/service/chat_service.go index 02e3f98a..215f0751 100644 --- a/internal/server/service/chat_service.go +++ b/internal/server/service/chat_service.go @@ -14,20 +14,23 @@ type chatServiceImpl struct { todoSvc domain.TodoService roleSvc domain.RoleService chatProvider domain.ChatProvider + toolSchema string } -// NewChatService 使用记忆、角色、任务清单和模型提供方依赖创建聊天服务。 -func NewChatService(memorySvc domain.MemoryService, workingSvc domain.WorkingMemoryService, todoSvc domain.TodoService, roleSvc domain.RoleService, chatProvider domain.ChatProvider) domain.ChatGateway { +// NewChatService creates a chat service with memory, role, todo, tool schema, +// and chat provider dependencies. +func NewChatService(memorySvc domain.MemoryService, workingSvc domain.WorkingMemoryService, todoSvc domain.TodoService, roleSvc domain.RoleService, chatProvider domain.ChatProvider, toolSchema string) domain.ChatGateway { return &chatServiceImpl{ memorySvc: memorySvc, workingSvc: workingSvc, todoSvc: todoSvc, roleSvc: roleSvc, chatProvider: chatProvider, + toolSchema: toolSchema, } } -// Send 为消息补充角色和记忆上下文后发起流式回复。 +// Send enriches the request with role and runtime context before streaming. func (s *chatServiceImpl) Send(ctx context.Context, req *domain.ChatRequest) (<-chan string, error) { messages := req.Messages @@ -35,16 +38,10 @@ func (s *chatServiceImpl) Send(ctx context.Context, req *domain.ChatRequest) (<- if err != nil { fmt.Printf("获取角色提示失败:%v\n", err) } else if rolePrompt != "" { - hasSystem := false - for _, msg := range messages { - if msg.Role == "system" { - hasSystem = true - break - } - } - - if !hasSystem { - // 角色提示总是放在最前面的 system 消息里,便于后续继续叠加记忆上下文。 + hasLeadingSystem := len(messages) > 0 && messages[0].Role == "system" + if !hasLeadingSystem { + // Keep the role prompt as the leading system message when the caller + // did not already provide one at the front of the conversation. messages = append([]domain.Message{{Role: "system", Content: rolePrompt}}, messages...) } } @@ -59,11 +56,14 @@ func (s *chatServiceImpl) Send(ctx context.Context, req *domain.ChatRequest) (<- } todoContext := "" if s.todoSvc != nil { - todos, _ := s.todoSvc.ListTodos(ctx) + todos, todoErr := s.todoSvc.ListTodos(ctx) + if todoErr != nil { + return nil, todoErr + } todoContext = buildTodoContext(todos) } - blocks := []string{workingContext, todoContext} + blocks := []string{s.toolSchema, workingContext, todoContext} if userInput != "" { memoryContext, ctxErr := s.memorySvc.BuildContext(ctx, userInput) if ctxErr != nil { @@ -72,7 +72,7 @@ func (s *chatServiceImpl) Send(ctx context.Context, req *domain.ChatRequest) (<- blocks = append(blocks, memoryContext) } combinedContext := joinContextBlocks(blocks...) - if combinedContext != "" { + if rolePrompt != "" || combinedContext != "" { messages = injectSystemContext(messages, rolePrompt, combinedContext) } @@ -93,8 +93,8 @@ func (s *chatServiceImpl) Send(ctx context.Context, req *domain.ChatRequest) (<- if s.latestUserInput(messages) != "" && replyBuilder.Len() > 0 { if s.workingSvc != nil { - // 工作记忆刷新要基于用户原始消息序列,而不是注入过 system 上下文后的 messages, - // 否则会把内部提示也误当成真实对话历史。 + // Refresh working memory from the original request messages so the + // injected system context does not become conversation history. updatedMessages := append([]domain.Message{}, req.Messages...) updatedMessages = append(updatedMessages, domain.Message{Role: "assistant", Content: replyBuilder.String()}) if err := s.workingSvc.Refresh(context.Background(), updatedMessages); err != nil { @@ -132,11 +132,19 @@ func buildTodoContext(todos []domain.Todo) string { } func injectSystemContext(messages []domain.Message, rolePrompt, combinedContext string) []domain.Message { - if rolePrompt != "" && len(messages) > 0 && messages[0].Role == "system" { - messages[0].Content = rolePrompt + "\n\n" + combinedContext + systemContext := joinContextBlocks(rolePrompt, combinedContext) + if systemContext == "" { + return messages + } + if len(messages) > 0 && messages[0].Role == "system" { + if rolePrompt != "" && strings.Contains(messages[0].Content, rolePrompt) { + messages[0].Content = joinContextBlocks(messages[0].Content, combinedContext) + return messages + } + messages[0].Content = joinContextBlocks(systemContext, messages[0].Content) return messages } - return append([]domain.Message{{Role: "system", Content: combinedContext}}, messages...) + return append([]domain.Message{{Role: "system", Content: systemContext}}, messages...) } func joinContextBlocks(blocks ...string) string { diff --git a/internal/server/service/chat_service_test.go b/internal/server/service/chat_service_test.go new file mode 100644 index 00000000..94ea9d09 --- /dev/null +++ b/internal/server/service/chat_service_test.go @@ -0,0 +1,219 @@ +package service + +import ( + "context" + "strings" + "testing" + + "go-llm-demo/internal/server/domain" +) + +type stubMemoryService struct { + buildContext string + saveCalls int +} + +func (s *stubMemoryService) BuildContext(context.Context, string) (string, error) { + return s.buildContext, nil +} + +func (s *stubMemoryService) Save(context.Context, string, string) error { + s.saveCalls++ + return nil +} + +func (s *stubMemoryService) GetStats(context.Context) (*domain.MemoryStats, error) { + return &domain.MemoryStats{}, nil +} + +func (s *stubMemoryService) Clear(context.Context) error { + return nil +} + +func (s *stubMemoryService) ClearSession(context.Context) error { + return nil +} + +type stubWorkingMemoryService struct { + buildContext string + refreshCalls int +} + +func (s *stubWorkingMemoryService) BuildContext(context.Context, []domain.Message) (string, error) { + return s.buildContext, nil +} + +func (s *stubWorkingMemoryService) Refresh(context.Context, []domain.Message) error { + s.refreshCalls++ + return nil +} + +func (s *stubWorkingMemoryService) Clear(context.Context) error { + return nil +} + +type stubTodoService struct { + todos []domain.Todo +} + +func (s *stubTodoService) AddTodo(context.Context, string, domain.TodoPriority) (*domain.Todo, error) { + return nil, nil +} + +func (s *stubTodoService) UpdateTodoStatus(context.Context, string, domain.TodoStatus) error { + return nil +} + +func (s *stubTodoService) ListTodos(context.Context) ([]domain.Todo, error) { + return append([]domain.Todo(nil), s.todos...), nil +} + +func (s *stubTodoService) ClearTodos(context.Context) error { + return nil +} + +func (s *stubTodoService) RemoveTodo(context.Context, string) error { + return nil +} + +type stubRoleService struct { + prompt string +} + +func (s *stubRoleService) GetActivePrompt(context.Context) (string, error) { + return s.prompt, nil +} + +func (s *stubRoleService) SetActive(context.Context, string) error { + return nil +} + +func (s *stubRoleService) List(context.Context) ([]domain.Role, error) { + return nil, nil +} + +func (s *stubRoleService) Create(context.Context, string, string, string) (*domain.Role, error) { + return nil, nil +} + +func (s *stubRoleService) Delete(context.Context, string) error { + return nil +} + +type captureChatProvider struct { + messages []domain.Message +} + +func (p *captureChatProvider) GetModelName() string { + return "test-model" +} + +func (p *captureChatProvider) Chat(_ context.Context, messages []domain.Message) (<-chan string, error) { + p.messages = append([]domain.Message(nil), messages...) + ch := make(chan string, 1) + ch <- "done" + close(ch) + return ch, nil +} + +func drain(ch <-chan string) { + for range ch { + } +} + +func TestSendInjectsToolSchemaIntoSystemContext(t *testing.T) { + mem := &stubMemoryService{buildContext: "[MEMORY]\nremember this"} + work := &stubWorkingMemoryService{buildContext: "[WORKING_MEMORY]\ncurrent task"} + todo := &stubTodoService{todos: []domain.Todo{{ID: "todo-1", Content: "write tests", Status: domain.TodoPending, Priority: domain.TodoPriorityHigh}}} + role := &stubRoleService{prompt: "You are NeoCode."} + provider := &captureChatProvider{} + + gateway := NewChatService(mem, work, todo, role, provider, "[TOOL_SCHEMAS]\n{}") + out, err := gateway.Send(context.Background(), &domain.ChatRequest{ + Messages: []domain.Message{{Role: "user", Content: "hello"}}, + Model: "test-model", + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + drain(out) + + if len(provider.messages) < 2 { + t.Fatalf("expected system prompt plus user message, got %+v", provider.messages) + } + if provider.messages[0].Role != "system" { + t.Fatalf("expected leading system message, got %+v", provider.messages[0]) + } + + systemContent := provider.messages[0].Content + for _, want := range []string{ + "You are NeoCode.", + "[TOOL_SCHEMAS]", + "[WORKING_MEMORY]", + "[TODO_LIST]", + "[MEMORY]", + } { + if !strings.Contains(systemContent, want) { + t.Fatalf("expected system context to contain %q, got %q", want, systemContent) + } + } + if mem.saveCalls != 1 { + t.Fatalf("expected memory save to be called once, got %d", mem.saveCalls) + } + if work.refreshCalls != 1 { + t.Fatalf("expected working memory refresh to be called once, got %d", work.refreshCalls) + } +} + +func TestSendPrependsRolePromptWhenSystemMessageExistsLater(t *testing.T) { + mem := &stubMemoryService{} + role := &stubRoleService{prompt: "You are NeoCode."} + provider := &captureChatProvider{} + + gateway := NewChatService(mem, nil, &stubTodoService{}, role, provider, "[TOOL_SCHEMAS]\n{}") + out, err := gateway.Send(context.Background(), &domain.ChatRequest{ + Messages: []domain.Message{ + {Role: "user", Content: "hello"}, + {Role: "system", Content: "[TOOL_CONTEXT]\nexisting"}, + }, + Model: "test-model", + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + drain(out) + + if len(provider.messages) == 0 { + t.Fatal("expected provider messages") + } + if provider.messages[0].Role != "system" { + t.Fatalf("expected prepended system message, got %+v", provider.messages) + } + if !strings.Contains(provider.messages[0].Content, "You are NeoCode.") { + t.Fatalf("expected role prompt to be preserved, got %q", provider.messages[0].Content) + } + if !strings.Contains(provider.messages[0].Content, "[TOOL_SCHEMAS]") { + t.Fatalf("expected tool schema to be injected, got %q", provider.messages[0].Content) + } +} + +func TestInjectSystemContextPreservesExistingLeadingSystemContent(t *testing.T) { + messages := []domain.Message{ + {Role: "system", Content: "existing system content"}, + {Role: "user", Content: "hello"}, + } + + got := injectSystemContext(messages, "role prompt", "[TOOL_SCHEMAS]\n{}") + if len(got) != 2 { + t.Fatalf("expected message count to stay the same, got %+v", got) + } + if !strings.Contains(got[0].Content, "role prompt") { + t.Fatalf("expected role prompt in leading system message, got %q", got[0].Content) + } + if !strings.Contains(got[0].Content, "[TOOL_SCHEMAS]") { + t.Fatalf("expected tool schema in leading system message, got %q", got[0].Content) + } + if !strings.Contains(got[0].Content, "existing system content") { + t.Fatalf("expected existing system content to be preserved, got %q", got[0].Content) + } +} diff --git a/internal/tui/services/api_client.go b/internal/tui/services/api_client.go index 11362b5a..e01c4b91 100644 --- a/internal/tui/services/api_client.go +++ b/internal/tui/services/api_client.go @@ -125,7 +125,11 @@ func (c *localChatClient) Chat(ctx context.Context, messages []Message, model st if err != nil { return nil, err } - chatSvc := service.NewChatService(c.memorySvc, c.workingSvc, c.todoSvc, c.roleSvc, chatProvider) + schemaPrompt, err := tools.GlobalSchemaPrompt() + if err != nil { + return nil, err + } + chatSvc := service.NewChatService(c.memorySvc, c.workingSvc, c.todoSvc, c.roleSvc, chatProvider, schemaPrompt) return chatSvc.Send(ctx, &domain.ChatRequest{Messages: messages, Model: model}) } From b237de6dc1cc51e4f1f8b952712e5fa781823835 Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Wed, 25 Mar 2026 17:45:50 +0800 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=B0=83=E7=94=A8=20JSON=20=E6=8D=95=E8=8E=B7=E5=99=A8?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/core/tool_call_capture.go | 230 ++++++++++++++++++++ internal/tui/core/tool_call_capture_test.go | 118 ++++++++++ internal/tui/core/update.go | 64 +++--- internal/tui/core/update_test.go | 8 +- 4 files changed, 386 insertions(+), 34 deletions(-) create mode 100644 internal/tui/core/tool_call_capture.go create mode 100644 internal/tui/core/tool_call_capture_test.go diff --git a/internal/tui/core/tool_call_capture.go b/internal/tui/core/tool_call_capture.go new file mode 100644 index 00000000..85291bfd --- /dev/null +++ b/internal/tui/core/tool_call_capture.go @@ -0,0 +1,230 @@ +package core + +import ( + "encoding/json" + "strings" + "unicode" + "unicode/utf8" + + "go-llm-demo/internal/tui/services" +) + +type toolCallCapture struct { + Call services.ToolCall + Start int + End int + CleanedResponse string +} + +func captureToolCallFromAssistantText(content string) (toolCallCapture, bool) { + candidate, ok := findLastToolCallCandidate(content) + if !ok { + return toolCallCapture{}, false + } + + candidate.CleanedResponse = sanitizeAssistantToolCallContent(content, candidate.Start, candidate.End) + return candidate, true +} + +func findLastToolCallCandidate(content string) (toolCallCapture, bool) { + var best toolCallCapture + found := false + + for start := range content { + if content[start] != '{' { + continue + } + + decoder := json.NewDecoder(strings.NewReader(content[start:])) + decoder.UseNumber() + + var raw map[string]json.RawMessage + if err := decoder.Decode(&raw); err != nil { + continue + } + + end := start + int(decoder.InputOffset()) + call, ok := parseToolCallCandidate(raw) + if !ok || !hasOnlyIgnorableToolCallSuffix(content[end:]) { + continue + } + + best = toolCallCapture{ + Call: call, + Start: start, + End: end, + } + found = true + } + + return best, found +} + +func parseToolCallCandidate(raw map[string]json.RawMessage) (services.ToolCall, bool) { + toolRaw, ok := raw["tool"] + if !ok { + return services.ToolCall{}, false + } + + var toolName string + if err := json.Unmarshal(toolRaw, &toolName); err != nil { + return services.ToolCall{}, false + } + toolName = strings.TrimSpace(toolName) + if toolName == "" { + return services.ToolCall{}, false + } + + params := map[string]interface{}{} + if paramsRaw, ok := raw["params"]; ok { + trimmed := strings.TrimSpace(string(paramsRaw)) + if trimmed != "" && trimmed != "null" { + if err := json.Unmarshal(paramsRaw, ¶ms); err != nil { + return services.ToolCall{}, false + } + } + } + + return services.ToolCall{ + Tool: toolName, + Params: params, + }, true +} + +func hasOnlyIgnorableToolCallSuffix(suffix string) bool { + remaining := strings.TrimSpace(suffix) + for remaining != "" { + switch { + case strings.HasPrefix(remaining, "```"): + remaining = strings.TrimSpace(remaining[3:]) + continue + case strings.HasPrefix(remaining, "") + if tagEnd <= 2 { + return false + } + tagName := remaining[2:tagEnd] + if !isIdentifier(tagName) { + return false + } + remaining = strings.TrimSpace(remaining[tagEnd+1:]) + continue + } + + r, size := utf8.DecodeRuneInString(remaining) + if size == 0 { + break + } + if strings.ContainsRune("`.,;:!?)]}\uFF0C\u3002\uFF1B\uFF1A\uFF01", r) { + remaining = strings.TrimSpace(remaining[size:]) + continue + } + return false + } + return true +} + +func sanitizeAssistantToolCallContent(content string, start, end int) string { + if start < 0 || end < start || end > len(content) { + return strings.TrimSpace(stripThinkBlocks(content)) + } + + cleaned := content[:start] + content[end:] + cleaned = stripThinkBlocks(cleaned) + cleaned = stripFenceOnlyLines(cleaned) + return collapseBlankLines(cleaned) +} + +func stripThinkBlocks(content string) string { + lower := strings.ToLower(content) + openTag := "" + closeTag := "" + + var builder strings.Builder + searchStart := 0 + for searchStart < len(content) { + openIdx := strings.Index(lower[searchStart:], openTag) + if openIdx < 0 { + builder.WriteString(content[searchStart:]) + break + } + openIdx += searchStart + builder.WriteString(content[searchStart:openIdx]) + + closeSearchStart := openIdx + len(openTag) + closeIdx := strings.Index(lower[closeSearchStart:], closeTag) + if closeIdx < 0 { + break + } + searchStart = closeSearchStart + closeIdx + len(closeTag) + } + + return builder.String() +} + +func stripFenceOnlyLines(content string) string { + lines := strings.Split(content, "\n") + kept := make([]string, 0, len(lines)) + for _, line := range lines { + if isFenceOnlyLine(strings.TrimSpace(line)) { + continue + } + kept = append(kept, strings.TrimRight(line, " \t")) + } + return strings.Join(kept, "\n") +} + +func isFenceOnlyLine(line string) bool { + if !strings.HasPrefix(line, "```") { + return false + } + + lang := strings.TrimSpace(strings.TrimPrefix(line, "```")) + return lang == "" || isIdentifier(lang) +} + +func collapseBlankLines(content string) string { + lines := strings.Split(strings.TrimSpace(content), "\n") + if len(lines) == 0 { + return "" + } + + collapsed := make([]string, 0, len(lines)) + prevBlank := false + for _, line := range lines { + blank := strings.TrimSpace(line) == "" + if blank { + if prevBlank { + continue + } + collapsed = append(collapsed, "") + prevBlank = true + continue + } + + collapsed = append(collapsed, line) + prevBlank = false + } + + return strings.TrimSpace(strings.Join(collapsed, "\n")) +} + +func isIdentifier(value string) bool { + value = strings.TrimSpace(value) + if value == "" { + return false + } + + for _, r := range value { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + continue + } + switch r { + case '_', '-', '+', '.': + continue + default: + return false + } + } + return true +} diff --git a/internal/tui/core/tool_call_capture_test.go b/internal/tui/core/tool_call_capture_test.go new file mode 100644 index 00000000..b8772d25 --- /dev/null +++ b/internal/tui/core/tool_call_capture_test.go @@ -0,0 +1,118 @@ +package core + +import ( + "strings" + "testing" + + "go-llm-demo/internal/tui/services" + "go-llm-demo/internal/tui/state" +) + +func TestCaptureToolCallFromAssistantTextSupportsMixedOutput(t *testing.T) { + content := "\nI should inspect the workspace first.\n\n我先查看当前工作区结构,确认放置 Go 程序的位置,避免覆盖现有文件。\n{\"tool\":\"list\",\"params\":{\"path\":\".\"}}" + + got, ok := captureToolCallFromAssistantText(content) + if !ok { + t.Fatal("expected tool call capture to succeed") + } + if got.Call.Tool != "list" { + t.Fatalf("expected list tool, got %q", got.Call.Tool) + } + if path, _ := got.Call.Params["path"].(string); path != "." { + t.Fatalf("expected path '.', got %+v", got.Call.Params) + } + if strings.Contains(got.CleanedResponse, "") { + t.Fatalf("expected think block to be removed, got %q", got.CleanedResponse) + } + if strings.Contains(got.CleanedResponse, `{"tool"`) { + t.Fatalf("expected tool JSON to be removed from cleaned response, got %q", got.CleanedResponse) + } + if !strings.Contains(got.CleanedResponse, "我先查看当前工作区结构") { + t.Fatalf("expected explanatory text to remain, got %q", got.CleanedResponse) + } +} + +func TestCaptureToolCallFromAssistantTextSupportsFencedJSON(t *testing.T) { + content := "```json\n{\"tool\":\"read\",\"params\":{\"filePath\":\"README.md\"}}\n```" + + got, ok := captureToolCallFromAssistantText(content) + if !ok { + t.Fatal("expected fenced tool call to be captured") + } + if got.Call.Tool != "read" { + t.Fatalf("expected read tool, got %q", got.Call.Tool) + } + if filePath, _ := got.Call.Params["filePath"].(string); filePath != "README.md" { + t.Fatalf("expected filePath README.md, got %+v", got.Call.Params) + } + if got.CleanedResponse != "" { + t.Fatalf("expected fenced JSON cleanup to remove empty wrapper, got %q", got.CleanedResponse) + } +} + +func TestCaptureToolCallFromAssistantTextRejectsTrailingNaturalLanguage(t *testing.T) { + content := "{\"tool\":\"list\",\"params\":{\"path\":\".\"}}\n然后我会继续解释目录结构。" + + if _, ok := captureToolCallFromAssistantText(content); ok { + t.Fatal("expected trailing natural language to prevent tool capture") + } +} + +func TestStreamDoneMsgExecutesToolCallFromMixedAssistantText(t *testing.T) { + client := &fakeChatClient{} + m := newTestModel(t, client) + m.chat.Generating = true + m.chat.Messages = []state.Message{{ + Role: "assistant", + Content: "\ninternal reasoning\n\n我先看一下目录结构。\n{\"tool\":\"list\",\"params\":{\"path\":\".\"}}", + Streaming: true, + }} + + expected := &services.ToolResult{ToolName: "list", Success: true, Output: "ok"} + executeToolCall = func(call services.ToolCall) *services.ToolResult { + if call.Tool != "list" { + t.Fatalf("expected list tool, got %q", call.Tool) + } + if path, _ := call.Params["path"].(string); path != "." { + t.Fatalf("expected normalized path '.', got %+v", call.Params) + } + return expected + } + + updated, cmd := m.Update(StreamDoneMsg{}) + got := updated.(Model) + + if !got.chat.ToolExecuting { + t.Fatal("expected tool execution flag to be set") + } + if cmd == nil { + t.Fatal("expected tool execution command") + } + if len(got.chat.Messages) != 2 { + t.Fatalf("expected cleaned assistant message and tool status, got %d messages", len(got.chat.Messages)) + } + if got.chat.Messages[0].Role != "assistant" { + t.Fatalf("expected assistant message to remain first, got %+v", got.chat.Messages[0]) + } + if strings.Contains(got.chat.Messages[0].Content, "") { + t.Fatalf("expected think block to be stripped, got %q", got.chat.Messages[0].Content) + } + if strings.Contains(got.chat.Messages[0].Content, `{"tool"`) { + t.Fatalf("expected tool JSON to be stripped, got %q", got.chat.Messages[0].Content) + } + if !strings.Contains(got.chat.Messages[0].Content, "我先看一下目录结构") { + t.Fatalf("expected explanatory text to remain, got %q", got.chat.Messages[0].Content) + } + if !strings.HasPrefix(got.chat.Messages[1].Content, toolStatusPrefix) { + t.Fatalf("expected transient tool status, got %q", got.chat.Messages[1].Content) + } + + msg := cmd() + resultMsg, ok := msg.(ToolResultMsg) + if !ok { + t.Fatalf("expected ToolResultMsg, got %T", msg) + } + if resultMsg.Result != expected { + t.Fatalf("expected tool result to round-trip, got %+v", resultMsg.Result) + } +} diff --git a/internal/tui/core/update.go b/internal/tui/core/update.go index e4985eba..02a89315 100644 --- a/internal/tui/core/update.go +++ b/internal/tui/core/update.go @@ -85,42 +85,46 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } mu.Unlock() - // 当前工具协议约定:模型如果想调用工具,需要把最后一条 assistant 消息完整输出为 - // {"tool":"...","params":{...}} 结构。这里在流结束后统一解析,避免半截 JSON 被误触发。 + // 当前工具协议约定:模型如果想调用工具,需要输出一个合法的工具调用 JSON。 + // 这里允许 JSON 前面带解释文本、代码围栏或 think 块,但只会提取最后一个合法调用。 if shouldCheckToolCall { - var jsonData map[string]interface{} - if err := json.Unmarshal([]byte(lastContent), &jsonData); err == nil { - if toolName, ok := jsonData["tool"].(string); ok && toolName != "" { - mu := m.mutex() - mu.Lock() - if m.chat.ToolExecuting { - mu.Unlock() - return m, nil + if captured, ok := captureToolCallFromAssistantText(lastContent); ok { + call := services.ToolCall{ + Tool: strings.TrimSpace(captured.Call.Tool), + Params: services.NormalizeToolParams(captured.Call.Params), + } + + mu := m.mutex() + mu.Lock() + if len(m.chat.Messages) > 0 { + lastIndex := len(m.chat.Messages) - 1 + if strings.TrimSpace(captured.CleanedResponse) == "" { + m.chat.Messages = append(m.chat.Messages[:lastIndex], m.chat.Messages[lastIndex+1:]...) + } else { + m.chat.Messages[lastIndex].Content = captured.CleanedResponse } - m.chat.ToolExecuting = true + } + if m.chat.ToolExecuting { mu.Unlock() + return m, nil + } + m.chat.ToolExecuting = true + mu.Unlock() - paramsMap := map[string]interface{}{} - if toolParams, ok := jsonData["params"].(map[string]interface{}); ok { - paramsMap = services.NormalizeToolParams(toolParams) - } + // 显示工具执行中提示(仅用于 UI,不参与模型上下文) + m.AddMessage("system", formatToolStatusMessage(call.Tool, call.Params)) - // 显示工具执行中提示(仅用于 UI,不参与模型上下文) - m.AddMessage("system", formatToolStatusMessage(toolName, paramsMap)) - - // 在goroutine中执行工具调用 - return m, func() tea.Msg { - call := services.ToolCall{Tool: toolName, Params: paramsMap} - result := executeToolCall(call) - if result == nil { - mu := m.mutex() - mu.Lock() - m.chat.ToolExecuting = false - mu.Unlock() - return ToolErrorMsg{Err: fmt.Errorf("tool execution failed: empty result")} - } - return ToolResultMsg{Result: result, Call: call} + // 在 goroutine 中执行工具调用 + return m, func() tea.Msg { + result := executeToolCall(call) + if result == nil { + mu := m.mutex() + mu.Lock() + m.chat.ToolExecuting = false + mu.Unlock() + return ToolErrorMsg{Err: fmt.Errorf("tool execution failed: empty result")} } + return ToolResultMsg{Result: result, Call: call} } } } diff --git a/internal/tui/core/update_test.go b/internal/tui/core/update_test.go index d9b0adae..912da1f3 100644 --- a/internal/tui/core/update_test.go +++ b/internal/tui/core/update_test.go @@ -1039,11 +1039,11 @@ func TestStreamDoneMsgExecutesToolCallFromAssistantJSON(t *testing.T) { if !got.chat.ToolExecuting { t.Fatal("expected tool execution flag to be set") } - if len(got.chat.Messages) != 2 { - t.Fatalf("expected tool status message to be appended, got %d messages", len(got.chat.Messages)) + if len(got.chat.Messages) != 1 { + t.Fatalf("expected pure tool JSON to be replaced by a single tool status message, got %d messages", len(got.chat.Messages)) } - if !strings.HasPrefix(got.chat.Messages[1].Content, toolStatusPrefix) { - t.Fatalf("expected transient tool status, got %q", got.chat.Messages[1].Content) + if !strings.HasPrefix(got.chat.Messages[0].Content, toolStatusPrefix) { + t.Fatalf("expected transient tool status, got %q", got.chat.Messages[0].Content) } if cmd == nil { t.Fatal("expected tool execution command") From 539b1fd86090485dc6a2a78accab385b9bd7da97 Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Wed, 25 Mar 2026 20:55:13 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E5=B7=A5?= =?UTF-8?q?=E5=85=B7schema=E4=B8=8Etool=5Fcalls=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/main.go | 9 +- internal/server/domain/chat.go | 36 ++- internal/server/domain/tool_schema.go | 27 ++ .../server/infra/provider/chat_provider.go | 123 ++++++++-- .../server/infra/provider/registry_test.go | 2 +- internal/server/infra/tools/schema_prompt.go | 43 ---- .../server/infra/tools/schema_prompt_test.go | 75 ------ internal/server/infra/tools/tool_schema.go | 54 ++++ .../server/infra/tools/tool_schema_test.go | 45 ++++ internal/server/service/chat_service.go | 24 +- internal/server/service/chat_service_test.go | 33 +-- internal/tui/core/model.go | 25 +- internal/tui/core/msg.go | 11 +- internal/tui/core/tool_call_capture.go | 230 ------------------ internal/tui/core/tool_call_capture_test.go | 118 --------- internal/tui/core/update.go | 196 ++++++++++----- internal/tui/core/update_test.go | 80 ++++-- internal/tui/core/view.go | 14 +- internal/tui/services/api_client.go | 11 +- internal/tui/services/runtime_services.go | 9 + internal/tui/state/chat_state.go | 11 +- 21 files changed, 545 insertions(+), 631 deletions(-) create mode 100644 internal/server/domain/tool_schema.go delete mode 100644 internal/server/infra/tools/schema_prompt.go delete mode 100644 internal/server/infra/tools/schema_prompt_test.go create mode 100644 internal/server/infra/tools/tool_schema.go create mode 100644 internal/server/infra/tools/tool_schema_test.go delete mode 100644 internal/tui/core/tool_call_capture.go delete mode 100644 internal/tui/core/tool_call_capture_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index b67a0883..a79e17ef 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -59,13 +59,8 @@ func main() { return } - schemaPrompt, err := tools.GlobalSchemaPrompt() - if err != nil { - fmt.Printf("生成工具 schema 上下文失败:%v\n", err) - return - } - - chatGateway := service.NewChatService(memorySvc, workingSvc, todoSvc, roleSvc, chatProvider, schemaPrompt) + toolSchemas := tools.BuildToolSchemas(tools.GlobalRegistry.ListDefinitions()) + chatGateway := service.NewChatService(memorySvc, workingSvc, todoSvc, roleSvc, chatProvider, toolSchemas) fmt.Printf("服务器已初始化并加载服务: %+v\n", chatGateway) fmt.Println("注意:这是一个占位符。实际的服务器实现将在此处进行.") } diff --git a/internal/server/domain/chat.go b/internal/server/domain/chat.go index 010e639c..87c1dd8a 100644 --- a/internal/server/domain/chat.go +++ b/internal/server/domain/chat.go @@ -7,18 +7,46 @@ import ( type ChatRequest struct { Messages []Message Model string + Tools []ToolSchema } type ChatGateway interface { - Send(ctx context.Context, req *ChatRequest) (<-chan string, error) + Send(ctx context.Context, req *ChatRequest) (<-chan ChatEvent, error) } type ChatProvider interface { GetModelName() string - Chat(ctx context.Context, messages []Message) (<-chan string, error) + Chat(ctx context.Context, messages []Message, tools []ToolSchema) (<-chan ChatEvent, error) } type Message struct { - Role string `json:"role"` - Content string `json:"content"` + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []ChatToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type ChatEventType string + +const ( + ChatEventDelta ChatEventType = "delta" + ChatEventToolCall ChatEventType = "tool_call" + ChatEventDone ChatEventType = "done" +) + +type ChatEvent struct { + Type ChatEventType + Content string + ToolCall *ChatToolCall +} + +type ChatToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function ChatToolCallFunction `json:"function"` +} + +type ChatToolCallFunction struct { + Name string `json:"name"` + Arguments string `json:"arguments"` } diff --git a/internal/server/domain/tool_schema.go b/internal/server/domain/tool_schema.go new file mode 100644 index 00000000..80e5187d --- /dev/null +++ b/internal/server/domain/tool_schema.go @@ -0,0 +1,27 @@ +package domain + +type ToolSchema struct { + Type string `json:"type"` + Function ToolFunctionSchema `json:"function"` +} + +type ToolFunctionSchema struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters ToolParametersSchema `json:"parameters"` +} + +type ToolParametersSchema struct { + Type string `json:"type"` + Properties map[string]ToolParamSchema `json:"properties,omitempty"` + Required []string `json:"required,omitempty"` + AdditionalProperties bool `json:"additionalProperties,omitempty"` +} + +type ToolParamSchema struct { + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + Enum []string `json:"enum,omitempty"` + Default interface{} `json:"default,omitempty"` + Items *ToolParamSchema `json:"items,omitempty"` +} diff --git a/internal/server/infra/provider/chat_provider.go b/internal/server/infra/provider/chat_provider.go index 93f3e424..32c1867e 100644 --- a/internal/server/infra/provider/chat_provider.go +++ b/internal/server/infra/provider/chat_provider.go @@ -11,6 +11,7 @@ import ( "io" "net" "net/http" + "sort" "strings" "time" @@ -24,8 +25,8 @@ const ( ) var ( - ErrInvalidAPIKey = errors.New("无效的 API Key") - ErrAPIKeyValidationSoft = errors.New("API Key 校验结果不确定") + ErrInvalidAPIKey = errors.New("鏃犳晥鐨?API Key") + ErrAPIKeyValidationSoft = errors.New("API Key 鏍¢獙缁撴灉涓嶇‘瀹?") ) type ChatCompletionProvider struct { @@ -34,7 +35,7 @@ type ChatCompletionProvider struct { Model string } -// GetModelName 返回提供方当前模型,缺省时使用默认模型。 +// GetModelName 杩斿洖鎻愪緵鏂瑰綋鍓嶆ā鍨嬶紝缂虹渷鏃朵娇鐢ㄩ粯璁ゆā鍨嬨€? func (p *ChatCompletionProvider) GetModelName() string { if p.Model != "" { return p.Model @@ -45,12 +46,24 @@ func (p *ChatCompletionProvider) GetModelName() string { type StreamResponse struct { Choices []struct { Delta struct { - Content string `json:"content"` + Content string `json:"content"` + ToolCalls []toolCallDelta `json:"tool_calls"` } `json:"delta"` + FinishReason string `json:"finish_reason"` } `json:"choices"` } -// NewChatProvider 为指定模型创建已配置的聊天提供方。 +type toolCallDelta struct { + Index int `json:"index"` + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` +} + +// NewChatProvider 涓烘寚瀹氭ā鍨嬪垱寤哄凡閰嶇疆鐨勮亰澶╂彁渚涙柟銆? func NewChatProvider(model string) (domain.ChatProvider, error) { if configs.GlobalAppConfig == nil { return nil, fmt.Errorf("config.yaml is not loaded") @@ -79,7 +92,7 @@ func NewChatProvider(model string) (domain.ChatProvider, error) { }, nil } -// ValidateChatAPIKey 按当前提供方配置校验运行时 API Key。 +// ValidateChatAPIKey 鎸夊綋鍓嶆彁渚涙柟閰嶇疆鏍¢獙杩愯鏃?API Key銆? func ValidateChatAPIKey(ctx context.Context, cfg *configs.AppConfiguration) error { if cfg == nil { return fmt.Errorf("config cannot be nil") @@ -99,8 +112,8 @@ func ValidateChatAPIKey(ctx context.Context, cfg *configs.AppConfiguration) erro return validateChatAPIKey(ctx, cfg) } -// Chat 向聊天补全接口发送流式请求并返回文本分片。 -func (p *ChatCompletionProvider) Chat(ctx context.Context, messages []domain.Message) (<-chan string, error) { +// Chat 鍚戣亰澶╄ˉ鍏ㄦ帴鍙e彂閫佹祦寮忚姹傚苟杩斿洖鏂囨湰鍒嗙墖銆? +func (p *ChatCompletionProvider) Chat(ctx context.Context, messages []domain.Message, tools []domain.ToolSchema) (<-chan domain.ChatEvent, error) { baseURL := strings.TrimSpace(p.BaseURL) if baseURL == "" { var err error @@ -116,6 +129,10 @@ func (p *ChatCompletionProvider) Chat(ctx context.Context, messages []domain.Mes "messages": messages, "stream": true, } + if len(tools) > 0 { + body["tools"] = tools + body["tool_choice"] = "auto" + } jsonData, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("chat request marshal failed: %w", err) @@ -148,13 +165,15 @@ func (p *ChatCompletionProvider) Chat(ctx context.Context, messages []domain.Mes return nil, fmt.Errorf("chat request failed: %s %s", resp.Status, strings.TrimSpace(string(body))) } - out := make(chan string) + out := make(chan domain.ChatEvent) go func() { defer close(out) defer resp.Body.Close() reader := bufio.NewReader(resp.Body) + toolCalls := map[int]*domain.ChatToolCall{} + hasToolCalls := false for { line, err := reader.ReadString('\n') if err != nil { @@ -173,18 +192,37 @@ func (p *ChatCompletionProvider) Chat(ctx context.Context, messages []domain.Mes break } - text, err := decodeStreamContent(data) + text, deltas, finishReason, err := decodeStreamDelta(data) if err != nil { emitStreamErrorMessage(ctx, out, err) return } - if text == "" { - continue + if text != "" { + text = stripThinkingTags(text) + select { + case <-ctx.Done(): + return + case out <- domain.ChatEvent{Type: domain.ChatEventDelta, Content: text}: + } } - select { - case <-ctx.Done(): - return - case out <- text: + if len(deltas) > 0 { + hasToolCalls = true + for _, delta := range deltas { + updateToolCall(toolCalls, delta) + } + } + if finishReason == "tool_calls" { + break + } + } + + if hasToolCalls { + for _, call := range orderedToolCalls(toolCalls) { + select { + case <-ctx.Done(): + return + case out <- domain.ChatEvent{Type: domain.ChatEventToolCall, ToolCall: call}: + } } } }() @@ -192,14 +230,14 @@ func (p *ChatCompletionProvider) Chat(ctx context.Context, messages []domain.Mes return out, nil } -func emitStreamErrorMessage(ctx context.Context, out chan<- string, err error) { +func emitStreamErrorMessage(ctx context.Context, out chan<- domain.ChatEvent, err error) { if err == nil { return } msg := fmt.Sprintf("\n[STREAM_ERROR] %v", err) select { case <-ctx.Done(): - case out <- msg: + case out <- domain.ChatEvent{Type: domain.ChatEventDelta, Content: msg}: } } @@ -213,17 +251,16 @@ func streamReadError(err error) error { return fmt.Errorf("chat stream read failed: %w", err) } -func decodeStreamContent(data string) (string, error) { +func decodeStreamDelta(data string) (string, []toolCallDelta, string, error) { var res StreamResponse if err := json.Unmarshal([]byte(data), &res); err != nil { - return "", fmt.Errorf("chat stream decode failed: %w", err) + return "", nil, "", fmt.Errorf("chat stream decode failed: %w", err) } if len(res.Choices) == 0 { - return "", nil + return "", nil, "", nil } - content := res.Choices[0].Delta.Content - content = stripThinkingTags(content) - return content, nil + choice := res.Choices[0] + return choice.Delta.Content, choice.Delta.ToolCalls, choice.FinishReason, nil } func stripThinkingTags(content string) string { @@ -244,6 +281,42 @@ func stripThinkingTags(content string) string { return content } +func updateToolCall(calls map[int]*domain.ChatToolCall, delta toolCallDelta) { + call, ok := calls[delta.Index] + if !ok { + call = &domain.ChatToolCall{Type: "function"} + calls[delta.Index] = call + } + if strings.TrimSpace(delta.ID) != "" { + call.ID = delta.ID + } + if strings.TrimSpace(delta.Type) != "" { + call.Type = delta.Type + } + if strings.TrimSpace(delta.Function.Name) != "" { + call.Function.Name = delta.Function.Name + } + if delta.Function.Arguments != "" { + call.Function.Arguments += delta.Function.Arguments + } +} + +func orderedToolCalls(calls map[int]*domain.ChatToolCall) []*domain.ChatToolCall { + if len(calls) == 0 { + return nil + } + indexes := make([]int, 0, len(calls)) + for idx := range calls { + indexes = append(indexes, idx) + } + sort.Ints(indexes) + ordered := make([]*domain.ChatToolCall, 0, len(indexes)) + for _, idx := range indexes { + ordered = append(ordered, calls[idx]) + } + return ordered +} + func httpClient() *http.Client { tr := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, @@ -286,7 +359,7 @@ func isRetryableError(err error) bool { func validateChatAPIKey(ctx context.Context, cfg *configs.AppConfiguration) error { if cfg == nil { - return fmt.Errorf("配置不能为空") + return fmt.Errorf("閰嶇疆涓嶈兘涓虹┖") } modelName := strings.TrimSpace(cfg.AI.Model) diff --git a/internal/server/infra/provider/registry_test.go b/internal/server/infra/provider/registry_test.go index 910e2125..c83028d5 100644 --- a/internal/server/infra/provider/registry_test.go +++ b/internal/server/infra/provider/registry_test.go @@ -97,7 +97,7 @@ func TestChatCompletionProviderChatReturnsErrorOnBadStatus(t *testing.T) { Model: "missing-model", } - stream, err := p.Chat(context.Background(), []domain.Message{{Role: "user", Content: "hi"}}) + stream, err := p.Chat(context.Background(), []domain.Message{{Role: "user", Content: "hi"}}, nil) if err == nil { if stream != nil { for range stream { diff --git a/internal/server/infra/tools/schema_prompt.go b/internal/server/infra/tools/schema_prompt.go deleted file mode 100644 index d835c7a6..00000000 --- a/internal/server/infra/tools/schema_prompt.go +++ /dev/null @@ -1,43 +0,0 @@ -package tools - -import ( - "encoding/json" - "fmt" - "strings" -) - -// GlobalSchemaPrompt renders the currently registered tool schemas as an -// English system-context block for the model. -func GlobalSchemaPrompt() (string, error) { - return BuildSchemaPrompt(GlobalRegistry.ListDefinitions()) -} - -// BuildSchemaPrompt converts tool definitions into a stable, machine-readable -// prompt block so the model can treat schema definitions as the single source -// of truth for tool calling. -func BuildSchemaPrompt(defs []ToolDefinition) (string, error) { - if len(defs) == 0 { - return "", nil - } - - payload := struct { - Tools []ToolDefinition `json:"tools"` - }{ - Tools: defs, - } - - encoded, err := json.MarshalIndent(payload, "", " ") - if err != nil { - return "", fmt.Errorf("marshal tool schema prompt: %w", err) - } - - parts := []string{ - "[TOOL_SCHEMAS]", - "Use these tool schemas as the single source of truth for tool names, parameter names, parameter types, required fields, default values, and enumerated choices.", - "When you need to call a tool, emit exactly one JSON object in the form {\"tool\":\"\",\"params\":{...}}.", - "Do not invent tool names or parameters. Omit optional parameters when they are unnecessary.", - string(encoded), - "[/TOOL_SCHEMAS]", - } - return strings.Join(parts, "\n"), nil -} diff --git a/internal/server/infra/tools/schema_prompt_test.go b/internal/server/infra/tools/schema_prompt_test.go deleted file mode 100644 index 492b6900..00000000 --- a/internal/server/infra/tools/schema_prompt_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package tools - -import ( - "strings" - "testing" -) - -func TestBuildSchemaPromptIncludesDefaultsAndEnums(t *testing.T) { - prompt, err := BuildSchemaPrompt([]ToolDefinition{ - { - Name: "todo", - Description: "Manage todos.", - Parameters: []ToolParamSpec{ - {Name: "action", Type: "string", Required: true, Enum: []string{"add", "list"}}, - {Name: "priority", Type: "string", DefaultValue: "medium"}, - {Name: "timeout", Type: "integer", DefaultValue: 120000}, - }, - }, - }) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if !strings.Contains(prompt, "[TOOL_SCHEMAS]") { - t.Fatalf("expected tool schema marker, got %q", prompt) - } - if !strings.Contains(prompt, `"name": "todo"`) { - t.Fatalf("expected tool name in prompt, got %q", prompt) - } - if !strings.Contains(prompt, `"default": "medium"`) { - t.Fatalf("expected string default in prompt, got %q", prompt) - } - if !strings.Contains(prompt, `"default": 120000`) { - t.Fatalf("expected integer default in prompt, got %q", prompt) - } - if !strings.Contains(prompt, `"enum": [`) || !strings.Contains(prompt, `"add"`) { - t.Fatalf("expected enum values in prompt, got %q", prompt) - } -} - -func TestBuildSchemaPromptHandlesEmptyDefinitions(t *testing.T) { - prompt, err := BuildSchemaPrompt(nil) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if prompt != "" { - t.Fatalf("expected empty prompt for empty schema, got %q", prompt) - } -} - -func TestToolDefinitionsExposeStructuredDefaults(t *testing.T) { - defs := NewToolRegistry().ListDefinitions() - var foundBash bool - - for _, def := range defs { - if def.Name == "bash" { - foundBash = true - var timeoutFound bool - for _, param := range def.Parameters { - if param.Name == "timeout" { - timeoutFound = true - if param.DefaultValue != 120000 { - t.Fatalf("expected bash timeout default 120000, got %#v", param.DefaultValue) - } - } - } - if !timeoutFound { - t.Fatal("expected bash timeout parameter") - } - } - } - - if !foundBash { - t.Fatal("expected bash definition to be registered") - } -} diff --git a/internal/server/infra/tools/tool_schema.go b/internal/server/infra/tools/tool_schema.go new file mode 100644 index 00000000..b5708897 --- /dev/null +++ b/internal/server/infra/tools/tool_schema.go @@ -0,0 +1,54 @@ +package tools + +import "go-llm-demo/internal/server/domain" + +func BuildToolSchemas(defs []domain.ToolDefinition) []domain.ToolSchema { + if len(defs) == 0 { + return nil + } + schemas := make([]domain.ToolSchema, 0, len(defs)) + for _, def := range defs { + schemas = append(schemas, buildToolSchema(def)) + } + return schemas +} + +func buildToolSchema(def domain.ToolDefinition) domain.ToolSchema { + properties := make(map[string]domain.ToolParamSchema, len(def.Parameters)) + required := make([]string, 0, len(def.Parameters)) + + for _, param := range def.Parameters { + properties[param.Name] = domain.ToolParamSchema{ + Type: mapParamType(param.Type), + Description: param.Description, + Enum: param.Enum, + Default: param.DefaultValue, + } + if param.Required { + required = append(required, param.Name) + } + } + + return domain.ToolSchema{ + Type: "function", + Function: domain.ToolFunctionSchema{ + Name: def.Name, + Description: def.Description, + Parameters: domain.ToolParametersSchema{ + Type: "object", + Properties: properties, + Required: required, + AdditionalProperties: false, + }, + }, + } +} + +func mapParamType(paramType string) string { + switch paramType { + case "string", "integer", "number", "boolean", "object", "array": + return paramType + default: + return "string" + } +} diff --git a/internal/server/infra/tools/tool_schema_test.go b/internal/server/infra/tools/tool_schema_test.go new file mode 100644 index 00000000..f30f59f6 --- /dev/null +++ b/internal/server/infra/tools/tool_schema_test.go @@ -0,0 +1,45 @@ +package tools + +import ( + "testing" + + "go-llm-demo/internal/server/domain" +) + +func TestBuildToolSchemasMapsRequiredAndDefaults(t *testing.T) { + defs := []domain.ToolDefinition{ + { + Name: "demo", + Description: "demo tool", + Parameters: []domain.ToolParamSpec{ + {Name: "path", Type: "string", Required: true, Description: "target path"}, + {Name: "limit", Type: "integer", DefaultValue: 10}, + {Name: "mode", Type: "string", Enum: []string{"fast", "slow"}}, + }, + }, + } + + got := BuildToolSchemas(defs) + if len(got) != 1 { + t.Fatalf("expected 1 tool schema, got %d", len(got)) + } + schema := got[0] + if schema.Type != "function" { + t.Fatalf("expected function schema, got %q", schema.Type) + } + if schema.Function.Name != "demo" { + t.Fatalf("expected tool name demo, got %q", schema.Function.Name) + } + if schema.Function.Parameters.Type != "object" { + t.Fatalf("expected parameters type object, got %q", schema.Function.Parameters.Type) + } + if len(schema.Function.Parameters.Required) != 1 || schema.Function.Parameters.Required[0] != "path" { + t.Fatalf("expected required path, got %+v", schema.Function.Parameters.Required) + } + if schema.Function.Parameters.Properties["limit"].Default != 10 { + t.Fatalf("expected limit default 10, got %+v", schema.Function.Parameters.Properties["limit"].Default) + } + if len(schema.Function.Parameters.Properties["mode"].Enum) != 2 { + t.Fatalf("expected enum values, got %+v", schema.Function.Parameters.Properties["mode"].Enum) + } +} diff --git a/internal/server/service/chat_service.go b/internal/server/service/chat_service.go index 215f0751..9b36ccda 100644 --- a/internal/server/service/chat_service.go +++ b/internal/server/service/chat_service.go @@ -14,24 +14,24 @@ type chatServiceImpl struct { todoSvc domain.TodoService roleSvc domain.RoleService chatProvider domain.ChatProvider - toolSchema string + tools []domain.ToolSchema } -// NewChatService creates a chat service with memory, role, todo, tool schema, +// NewChatService creates a chat service with memory, role, todo, tool schemas, // and chat provider dependencies. -func NewChatService(memorySvc domain.MemoryService, workingSvc domain.WorkingMemoryService, todoSvc domain.TodoService, roleSvc domain.RoleService, chatProvider domain.ChatProvider, toolSchema string) domain.ChatGateway { +func NewChatService(memorySvc domain.MemoryService, workingSvc domain.WorkingMemoryService, todoSvc domain.TodoService, roleSvc domain.RoleService, chatProvider domain.ChatProvider, tools []domain.ToolSchema) domain.ChatGateway { return &chatServiceImpl{ memorySvc: memorySvc, workingSvc: workingSvc, todoSvc: todoSvc, roleSvc: roleSvc, chatProvider: chatProvider, - toolSchema: toolSchema, + tools: tools, } } // Send enriches the request with role and runtime context before streaming. -func (s *chatServiceImpl) Send(ctx context.Context, req *domain.ChatRequest) (<-chan string, error) { +func (s *chatServiceImpl) Send(ctx context.Context, req *domain.ChatRequest) (<-chan domain.ChatEvent, error) { messages := req.Messages rolePrompt, err := s.roleSvc.GetActivePrompt(ctx) @@ -63,7 +63,7 @@ func (s *chatServiceImpl) Send(ctx context.Context, req *domain.ChatRequest) (<- todoContext = buildTodoContext(todos) } - blocks := []string{s.toolSchema, workingContext, todoContext} + blocks := []string{workingContext, todoContext} if userInput != "" { memoryContext, ctxErr := s.memorySvc.BuildContext(ctx, userInput) if ctxErr != nil { @@ -76,19 +76,21 @@ func (s *chatServiceImpl) Send(ctx context.Context, req *domain.ChatRequest) (<- messages = injectSystemContext(messages, rolePrompt, combinedContext) } - out, err := s.chatProvider.Chat(ctx, messages) + out, err := s.chatProvider.Chat(ctx, messages, s.tools) if err != nil { return nil, err } - resultChan := make(chan string) + resultChan := make(chan domain.ChatEvent) go func() { defer close(resultChan) var replyBuilder strings.Builder - for chunk := range out { - replyBuilder.WriteString(chunk) - resultChan <- chunk + for event := range out { + if event.Type == domain.ChatEventDelta { + replyBuilder.WriteString(event.Content) + } + resultChan <- event } if s.latestUserInput(messages) != "" && replyBuilder.Len() > 0 { diff --git a/internal/server/service/chat_service_test.go b/internal/server/service/chat_service_test.go index 94ea9d09..6b13e76f 100644 --- a/internal/server/service/chat_service_test.go +++ b/internal/server/service/chat_service_test.go @@ -52,6 +52,10 @@ func (s *stubWorkingMemoryService) Clear(context.Context) error { return nil } +func (s *stubWorkingMemoryService) Get(context.Context) (*domain.WorkingMemoryState, error) { + return &domain.WorkingMemoryState{}, nil +} + type stubTodoService struct { todos []domain.Todo } @@ -102,33 +106,36 @@ func (s *stubRoleService) Delete(context.Context, string) error { type captureChatProvider struct { messages []domain.Message + tools []domain.ToolSchema } func (p *captureChatProvider) GetModelName() string { return "test-model" } -func (p *captureChatProvider) Chat(_ context.Context, messages []domain.Message) (<-chan string, error) { +func (p *captureChatProvider) Chat(_ context.Context, messages []domain.Message, tools []domain.ToolSchema) (<-chan domain.ChatEvent, error) { p.messages = append([]domain.Message(nil), messages...) - ch := make(chan string, 1) - ch <- "done" + p.tools = append([]domain.ToolSchema(nil), tools...) + ch := make(chan domain.ChatEvent, 1) + ch <- domain.ChatEvent{Type: domain.ChatEventDelta, Content: "done"} close(ch) return ch, nil } -func drain(ch <-chan string) { +func drain(ch <-chan domain.ChatEvent) { for range ch { } } -func TestSendInjectsToolSchemaIntoSystemContext(t *testing.T) { +func TestSendPassesToolsAndInjectsRuntimeContext(t *testing.T) { mem := &stubMemoryService{buildContext: "[MEMORY]\nremember this"} work := &stubWorkingMemoryService{buildContext: "[WORKING_MEMORY]\ncurrent task"} todo := &stubTodoService{todos: []domain.Todo{{ID: "todo-1", Content: "write tests", Status: domain.TodoPending, Priority: domain.TodoPriorityHigh}}} role := &stubRoleService{prompt: "You are NeoCode."} provider := &captureChatProvider{} + toolSchemas := []domain.ToolSchema{{Type: "function", Function: domain.ToolFunctionSchema{Name: "list"}}} - gateway := NewChatService(mem, work, todo, role, provider, "[TOOL_SCHEMAS]\n{}") + gateway := NewChatService(mem, work, todo, role, provider, toolSchemas) out, err := gateway.Send(context.Background(), &domain.ChatRequest{ Messages: []domain.Message{{Role: "user", Content: "hello"}}, Model: "test-model", @@ -144,11 +151,13 @@ func TestSendInjectsToolSchemaIntoSystemContext(t *testing.T) { if provider.messages[0].Role != "system" { t.Fatalf("expected leading system message, got %+v", provider.messages[0]) } + if len(provider.tools) != 1 || provider.tools[0].Function.Name != "list" { + t.Fatalf("expected tools to be passed through, got %+v", provider.tools) + } systemContent := provider.messages[0].Content for _, want := range []string{ "You are NeoCode.", - "[TOOL_SCHEMAS]", "[WORKING_MEMORY]", "[TODO_LIST]", "[MEMORY]", @@ -170,7 +179,7 @@ func TestSendPrependsRolePromptWhenSystemMessageExistsLater(t *testing.T) { role := &stubRoleService{prompt: "You are NeoCode."} provider := &captureChatProvider{} - gateway := NewChatService(mem, nil, &stubTodoService{}, role, provider, "[TOOL_SCHEMAS]\n{}") + gateway := NewChatService(mem, nil, &stubTodoService{}, role, provider, nil) out, err := gateway.Send(context.Background(), &domain.ChatRequest{ Messages: []domain.Message{ {Role: "user", Content: "hello"}, @@ -192,9 +201,6 @@ func TestSendPrependsRolePromptWhenSystemMessageExistsLater(t *testing.T) { if !strings.Contains(provider.messages[0].Content, "You are NeoCode.") { t.Fatalf("expected role prompt to be preserved, got %q", provider.messages[0].Content) } - if !strings.Contains(provider.messages[0].Content, "[TOOL_SCHEMAS]") { - t.Fatalf("expected tool schema to be injected, got %q", provider.messages[0].Content) - } } func TestInjectSystemContextPreservesExistingLeadingSystemContent(t *testing.T) { @@ -203,16 +209,13 @@ func TestInjectSystemContextPreservesExistingLeadingSystemContent(t *testing.T) {Role: "user", Content: "hello"}, } - got := injectSystemContext(messages, "role prompt", "[TOOL_SCHEMAS]\n{}") + got := injectSystemContext(messages, "role prompt", "runtime context") if len(got) != 2 { t.Fatalf("expected message count to stay the same, got %+v", got) } if !strings.Contains(got[0].Content, "role prompt") { t.Fatalf("expected role prompt in leading system message, got %q", got[0].Content) } - if !strings.Contains(got[0].Content, "[TOOL_SCHEMAS]") { - t.Fatalf("expected tool schema in leading system message, got %q", got[0].Content) - } if !strings.Contains(got[0].Content, "existing system content") { t.Fatalf("expected existing system content to be preserved, got %q", got[0].Content) } diff --git a/internal/tui/core/model.go b/internal/tui/core/model.go index 9131ca6a..551b236d 100644 --- a/internal/tui/core/model.go +++ b/internal/tui/core/model.go @@ -26,7 +26,7 @@ type Model struct { client services.ChatClient persona string - streamChan <-chan string + streamChan <-chan services.ChatEvent textarea textarea.Model viewport viewport.Model chatLayout components.RenderedChatLayout @@ -144,6 +144,29 @@ func (m *Model) AddMessage(role, content string) { }) } +func (m *Model) AddToolCallMessage(calls []services.ChatToolCall) { + mu := m.mutex() + mu.Lock() + defer mu.Unlock() + m.chat.Messages = append(m.chat.Messages, state.Message{ + Role: "assistant", + ToolCalls: calls, + Timestamp: time.Now(), + }) +} + +func (m *Model) AddToolMessage(callID, content string) { + mu := m.mutex() + mu.Lock() + defer mu.Unlock() + m.chat.Messages = append(m.chat.Messages, state.Message{ + Role: "tool", + Content: content, + ToolCallID: callID, + Timestamp: time.Now(), + }) +} + // AppendLastMessage 将流式内容追加到最后一条消息中。 func (m *Model) AppendLastMessage(content string) { mu := m.mutex() diff --git a/internal/tui/core/msg.go b/internal/tui/core/msg.go index 67ef7211..c7e7660a 100644 --- a/internal/tui/core/msg.go +++ b/internal/tui/core/msg.go @@ -18,15 +18,24 @@ type StreamErrorMsg struct { func (StreamErrorMsg) isMsg() {} +type StreamToolCallMsg struct { + Call services.ToolCall + CallID string +} + +func (StreamToolCallMsg) isMsg() {} + type ToolResultMsg struct { Result *services.ToolResult Call services.ToolCall + CallID string } func (ToolResultMsg) isMsg() {} type ToolErrorMsg struct { - Err error + Err error + CallID string } func (ToolErrorMsg) isMsg() {} diff --git a/internal/tui/core/tool_call_capture.go b/internal/tui/core/tool_call_capture.go deleted file mode 100644 index 85291bfd..00000000 --- a/internal/tui/core/tool_call_capture.go +++ /dev/null @@ -1,230 +0,0 @@ -package core - -import ( - "encoding/json" - "strings" - "unicode" - "unicode/utf8" - - "go-llm-demo/internal/tui/services" -) - -type toolCallCapture struct { - Call services.ToolCall - Start int - End int - CleanedResponse string -} - -func captureToolCallFromAssistantText(content string) (toolCallCapture, bool) { - candidate, ok := findLastToolCallCandidate(content) - if !ok { - return toolCallCapture{}, false - } - - candidate.CleanedResponse = sanitizeAssistantToolCallContent(content, candidate.Start, candidate.End) - return candidate, true -} - -func findLastToolCallCandidate(content string) (toolCallCapture, bool) { - var best toolCallCapture - found := false - - for start := range content { - if content[start] != '{' { - continue - } - - decoder := json.NewDecoder(strings.NewReader(content[start:])) - decoder.UseNumber() - - var raw map[string]json.RawMessage - if err := decoder.Decode(&raw); err != nil { - continue - } - - end := start + int(decoder.InputOffset()) - call, ok := parseToolCallCandidate(raw) - if !ok || !hasOnlyIgnorableToolCallSuffix(content[end:]) { - continue - } - - best = toolCallCapture{ - Call: call, - Start: start, - End: end, - } - found = true - } - - return best, found -} - -func parseToolCallCandidate(raw map[string]json.RawMessage) (services.ToolCall, bool) { - toolRaw, ok := raw["tool"] - if !ok { - return services.ToolCall{}, false - } - - var toolName string - if err := json.Unmarshal(toolRaw, &toolName); err != nil { - return services.ToolCall{}, false - } - toolName = strings.TrimSpace(toolName) - if toolName == "" { - return services.ToolCall{}, false - } - - params := map[string]interface{}{} - if paramsRaw, ok := raw["params"]; ok { - trimmed := strings.TrimSpace(string(paramsRaw)) - if trimmed != "" && trimmed != "null" { - if err := json.Unmarshal(paramsRaw, ¶ms); err != nil { - return services.ToolCall{}, false - } - } - } - - return services.ToolCall{ - Tool: toolName, - Params: params, - }, true -} - -func hasOnlyIgnorableToolCallSuffix(suffix string) bool { - remaining := strings.TrimSpace(suffix) - for remaining != "" { - switch { - case strings.HasPrefix(remaining, "```"): - remaining = strings.TrimSpace(remaining[3:]) - continue - case strings.HasPrefix(remaining, "") - if tagEnd <= 2 { - return false - } - tagName := remaining[2:tagEnd] - if !isIdentifier(tagName) { - return false - } - remaining = strings.TrimSpace(remaining[tagEnd+1:]) - continue - } - - r, size := utf8.DecodeRuneInString(remaining) - if size == 0 { - break - } - if strings.ContainsRune("`.,;:!?)]}\uFF0C\u3002\uFF1B\uFF1A\uFF01", r) { - remaining = strings.TrimSpace(remaining[size:]) - continue - } - return false - } - return true -} - -func sanitizeAssistantToolCallContent(content string, start, end int) string { - if start < 0 || end < start || end > len(content) { - return strings.TrimSpace(stripThinkBlocks(content)) - } - - cleaned := content[:start] + content[end:] - cleaned = stripThinkBlocks(cleaned) - cleaned = stripFenceOnlyLines(cleaned) - return collapseBlankLines(cleaned) -} - -func stripThinkBlocks(content string) string { - lower := strings.ToLower(content) - openTag := "" - closeTag := "" - - var builder strings.Builder - searchStart := 0 - for searchStart < len(content) { - openIdx := strings.Index(lower[searchStart:], openTag) - if openIdx < 0 { - builder.WriteString(content[searchStart:]) - break - } - openIdx += searchStart - builder.WriteString(content[searchStart:openIdx]) - - closeSearchStart := openIdx + len(openTag) - closeIdx := strings.Index(lower[closeSearchStart:], closeTag) - if closeIdx < 0 { - break - } - searchStart = closeSearchStart + closeIdx + len(closeTag) - } - - return builder.String() -} - -func stripFenceOnlyLines(content string) string { - lines := strings.Split(content, "\n") - kept := make([]string, 0, len(lines)) - for _, line := range lines { - if isFenceOnlyLine(strings.TrimSpace(line)) { - continue - } - kept = append(kept, strings.TrimRight(line, " \t")) - } - return strings.Join(kept, "\n") -} - -func isFenceOnlyLine(line string) bool { - if !strings.HasPrefix(line, "```") { - return false - } - - lang := strings.TrimSpace(strings.TrimPrefix(line, "```")) - return lang == "" || isIdentifier(lang) -} - -func collapseBlankLines(content string) string { - lines := strings.Split(strings.TrimSpace(content), "\n") - if len(lines) == 0 { - return "" - } - - collapsed := make([]string, 0, len(lines)) - prevBlank := false - for _, line := range lines { - blank := strings.TrimSpace(line) == "" - if blank { - if prevBlank { - continue - } - collapsed = append(collapsed, "") - prevBlank = true - continue - } - - collapsed = append(collapsed, line) - prevBlank = false - } - - return strings.TrimSpace(strings.Join(collapsed, "\n")) -} - -func isIdentifier(value string) bool { - value = strings.TrimSpace(value) - if value == "" { - return false - } - - for _, r := range value { - if unicode.IsLetter(r) || unicode.IsDigit(r) { - continue - } - switch r { - case '_', '-', '+', '.': - continue - default: - return false - } - } - return true -} diff --git a/internal/tui/core/tool_call_capture_test.go b/internal/tui/core/tool_call_capture_test.go deleted file mode 100644 index b8772d25..00000000 --- a/internal/tui/core/tool_call_capture_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package core - -import ( - "strings" - "testing" - - "go-llm-demo/internal/tui/services" - "go-llm-demo/internal/tui/state" -) - -func TestCaptureToolCallFromAssistantTextSupportsMixedOutput(t *testing.T) { - content := "\nI should inspect the workspace first.\n\n我先查看当前工作区结构,确认放置 Go 程序的位置,避免覆盖现有文件。\n{\"tool\":\"list\",\"params\":{\"path\":\".\"}}" - - got, ok := captureToolCallFromAssistantText(content) - if !ok { - t.Fatal("expected tool call capture to succeed") - } - if got.Call.Tool != "list" { - t.Fatalf("expected list tool, got %q", got.Call.Tool) - } - if path, _ := got.Call.Params["path"].(string); path != "." { - t.Fatalf("expected path '.', got %+v", got.Call.Params) - } - if strings.Contains(got.CleanedResponse, "") { - t.Fatalf("expected think block to be removed, got %q", got.CleanedResponse) - } - if strings.Contains(got.CleanedResponse, `{"tool"`) { - t.Fatalf("expected tool JSON to be removed from cleaned response, got %q", got.CleanedResponse) - } - if !strings.Contains(got.CleanedResponse, "我先查看当前工作区结构") { - t.Fatalf("expected explanatory text to remain, got %q", got.CleanedResponse) - } -} - -func TestCaptureToolCallFromAssistantTextSupportsFencedJSON(t *testing.T) { - content := "```json\n{\"tool\":\"read\",\"params\":{\"filePath\":\"README.md\"}}\n```" - - got, ok := captureToolCallFromAssistantText(content) - if !ok { - t.Fatal("expected fenced tool call to be captured") - } - if got.Call.Tool != "read" { - t.Fatalf("expected read tool, got %q", got.Call.Tool) - } - if filePath, _ := got.Call.Params["filePath"].(string); filePath != "README.md" { - t.Fatalf("expected filePath README.md, got %+v", got.Call.Params) - } - if got.CleanedResponse != "" { - t.Fatalf("expected fenced JSON cleanup to remove empty wrapper, got %q", got.CleanedResponse) - } -} - -func TestCaptureToolCallFromAssistantTextRejectsTrailingNaturalLanguage(t *testing.T) { - content := "{\"tool\":\"list\",\"params\":{\"path\":\".\"}}\n然后我会继续解释目录结构。" - - if _, ok := captureToolCallFromAssistantText(content); ok { - t.Fatal("expected trailing natural language to prevent tool capture") - } -} - -func TestStreamDoneMsgExecutesToolCallFromMixedAssistantText(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.Generating = true - m.chat.Messages = []state.Message{{ - Role: "assistant", - Content: "\ninternal reasoning\n\n我先看一下目录结构。\n{\"tool\":\"list\",\"params\":{\"path\":\".\"}}", - Streaming: true, - }} - - expected := &services.ToolResult{ToolName: "list", Success: true, Output: "ok"} - executeToolCall = func(call services.ToolCall) *services.ToolResult { - if call.Tool != "list" { - t.Fatalf("expected list tool, got %q", call.Tool) - } - if path, _ := call.Params["path"].(string); path != "." { - t.Fatalf("expected normalized path '.', got %+v", call.Params) - } - return expected - } - - updated, cmd := m.Update(StreamDoneMsg{}) - got := updated.(Model) - - if !got.chat.ToolExecuting { - t.Fatal("expected tool execution flag to be set") - } - if cmd == nil { - t.Fatal("expected tool execution command") - } - if len(got.chat.Messages) != 2 { - t.Fatalf("expected cleaned assistant message and tool status, got %d messages", len(got.chat.Messages)) - } - if got.chat.Messages[0].Role != "assistant" { - t.Fatalf("expected assistant message to remain first, got %+v", got.chat.Messages[0]) - } - if strings.Contains(got.chat.Messages[0].Content, "") { - t.Fatalf("expected think block to be stripped, got %q", got.chat.Messages[0].Content) - } - if strings.Contains(got.chat.Messages[0].Content, `{"tool"`) { - t.Fatalf("expected tool JSON to be stripped, got %q", got.chat.Messages[0].Content) - } - if !strings.Contains(got.chat.Messages[0].Content, "我先看一下目录结构") { - t.Fatalf("expected explanatory text to remain, got %q", got.chat.Messages[0].Content) - } - if !strings.HasPrefix(got.chat.Messages[1].Content, toolStatusPrefix) { - t.Fatalf("expected transient tool status, got %q", got.chat.Messages[1].Content) - } - - msg := cmd() - resultMsg, ok := msg.(ToolResultMsg) - if !ok { - t.Fatalf("expected ToolResultMsg, got %T", msg) - } - if resultMsg.Result != expected { - t.Fatalf("expected tool result to round-trip, got %+v", resultMsg.Result) - } -} diff --git a/internal/tui/core/update.go b/internal/tui/core/update.go index 02a89315..d6582d63 100644 --- a/internal/tui/core/update.go +++ b/internal/tui/core/update.go @@ -66,68 +66,55 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.streamResponseFromChannel() + case StreamToolCallMsg: + if strings.TrimSpace(msg.Call.Tool) == "" { + return m, func() tea.Msg { + return ToolErrorMsg{Err: fmt.Errorf("tool call is missing tool name"), CallID: msg.CallID} + } + } + + toolCall, err := buildAssistantToolCall(msg.CallID, msg.Call) + if err != nil { + return m, func() tea.Msg { return ToolErrorMsg{Err: err, CallID: msg.CallID} } + } + m.AddToolCallMessage([]services.ChatToolCall{toolCall}) + + mu := m.mutex() + mu.Lock() + if m.chat.ToolExecuting { + mu.Unlock() + return m, nil + } + m.chat.ToolExecuting = true + mu.Unlock() + + // 显示工具执行中提示(仅用于 UI,不参与模型上下文) + m.AddMessage("system", formatToolStatusMessage(msg.Call.Tool, msg.Call.Params)) + + return m, func() tea.Msg { + result := executeToolCall(msg.Call) + if result == nil { + mu := m.mutex() + mu.Lock() + m.chat.ToolExecuting = false + mu.Unlock() + return ToolErrorMsg{Err: fmt.Errorf("tool execution failed: empty result"), CallID: msg.CallID} + } + return ToolResultMsg{Result: result, Call: msg.Call, CallID: msg.CallID} + } + case StreamDoneMsg: mu := m.mutex() mu.Lock() m.chat.Generating = false m.streamChan = nil - var lastContent string - shouldCheckToolCall := !m.chat.ToolExecuting && len(m.chat.Messages) > 0 if len(m.chat.Messages) > 0 { lastMsg := &m.chat.Messages[len(m.chat.Messages)-1] lastMsg.Streaming = false - if lastMsg.Role == "assistant" { - lastContent = lastMsg.Content - } else { - shouldCheckToolCall = false - } } mu.Unlock() - // 当前工具协议约定:模型如果想调用工具,需要输出一个合法的工具调用 JSON。 - // 这里允许 JSON 前面带解释文本、代码围栏或 think 块,但只会提取最后一个合法调用。 - if shouldCheckToolCall { - if captured, ok := captureToolCallFromAssistantText(lastContent); ok { - call := services.ToolCall{ - Tool: strings.TrimSpace(captured.Call.Tool), - Params: services.NormalizeToolParams(captured.Call.Params), - } - - mu := m.mutex() - mu.Lock() - if len(m.chat.Messages) > 0 { - lastIndex := len(m.chat.Messages) - 1 - if strings.TrimSpace(captured.CleanedResponse) == "" { - m.chat.Messages = append(m.chat.Messages[:lastIndex], m.chat.Messages[lastIndex+1:]...) - } else { - m.chat.Messages[lastIndex].Content = captured.CleanedResponse - } - } - if m.chat.ToolExecuting { - mu.Unlock() - return m, nil - } - m.chat.ToolExecuting = true - mu.Unlock() - - // 显示工具执行中提示(仅用于 UI,不参与模型上下文) - m.AddMessage("system", formatToolStatusMessage(call.Tool, call.Params)) - - // 在 goroutine 中执行工具调用 - return m, func() tea.Msg { - result := executeToolCall(call) - if result == nil { - mu := m.mutex() - mu.Lock() - m.chat.ToolExecuting = false - mu.Unlock() - return ToolErrorMsg{Err: fmt.Errorf("tool execution failed: empty result")} - } - return ToolResultMsg{Result: result, Call: call} - } - } - } m.refreshViewport() return m, nil @@ -188,6 +175,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Call: msg.Call, ToolType: toolType, Target: target, + CallID: msg.CallID, } pending := m.chat.PendingApproval mu.Unlock() @@ -197,6 +185,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.AddMessage("system", formatToolContextMessage(msg.Result)) + m.AddToolMessage(msg.CallID, buildToolResultContent(msg.Result)) m.AddMessage("assistant", "") m.chat.Generating = true m.refreshViewport() @@ -212,6 +201,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { mu.Unlock() // 将工具执行错误添加为结构化系统上下文 m.AddMessage("system", formatToolErrorContext(msg.Err)) + if strings.TrimSpace(msg.CallID) != "" { + m.AddToolMessage(msg.CallID, msg.Err.Error()) + } m.AddMessage("assistant", "") m.chat.Generating = true m.refreshViewport() @@ -442,9 +434,9 @@ func (m *Model) handleCommand(input string) (tea.Model, tea.Cmd) { mu.Lock() m.chat.ToolExecuting = false mu.Unlock() - return ToolErrorMsg{Err: fmt.Errorf("tool execution failed: empty result")} + return ToolErrorMsg{Err: fmt.Errorf("tool execution failed: empty result"), CallID: pending.CallID} } - return ToolResultMsg{Result: result, Call: pending.Call} + return ToolResultMsg{Result: result, Call: pending.Call, CallID: pending.CallID} } case "/n": if len(args) > 0 { @@ -805,13 +797,15 @@ func (m *Model) buildMessages() []services.Message { } } // 跳过空的 assistant 消息 - if msg.Role == "assistant" && strings.TrimSpace(msg.Content) == "" { + if msg.Role == "assistant" && strings.TrimSpace(msg.Content) == "" && len(msg.ToolCalls) == 0 { continue } // 将非空消息按其原始角色和内容添加到结果中 result = append(result, services.Message{ - Role: msg.Role, - Content: msg.Content, + Role: msg.Role, + Content: msg.Content, + ToolCalls: msg.ToolCalls, + ToolCallID: msg.ToolCallID, }) } @@ -826,11 +820,11 @@ func (m *Model) streamResponse(messages []services.Message) tea.Cmd { m.streamChan = stream return func() tea.Msg { - chunk, ok := <-stream + event, ok := <-stream if !ok { return StreamDoneMsg{} } - return StreamChunkMsg{Content: chunk} + return mapChatEventToMsg(event) } } @@ -839,11 +833,11 @@ func (m *Model) streamResponseFromChannel() tea.Cmd { return nil } return func() tea.Msg { - chunk, ok := <-m.streamChan + event, ok := <-m.streamChan if !ok { return StreamDoneMsg{} } - return StreamChunkMsg{Content: chunk} + return mapChatEventToMsg(event) } } @@ -860,6 +854,90 @@ func (m *Model) sendCodeToAI(code string) tea.Cmd { return m.streamResponse(messages) } +func mapChatEventToMsg(event services.ChatEvent) tea.Msg { + switch event.Type { + case services.ChatEventDelta: + return StreamChunkMsg{Content: event.Content} + case services.ChatEventToolCall: + call, err := buildToolCallFromEvent(event.ToolCall) + if err != nil { + return StreamErrorMsg{Err: err} + } + return StreamToolCallMsg{Call: call, CallID: event.ToolCall.ID} + case services.ChatEventDone: + return StreamDoneMsg{} + default: + if strings.TrimSpace(event.Content) != "" { + return StreamChunkMsg{Content: event.Content} + } + return StreamDoneMsg{} + } +} + +func buildToolCallFromEvent(call *services.ChatToolCall) (services.ToolCall, error) { + if call == nil { + return services.ToolCall{}, fmt.Errorf("tool call event is nil") + } + toolName := strings.TrimSpace(call.Function.Name) + if toolName == "" { + return services.ToolCall{}, fmt.Errorf("tool call is missing function name") + } + params, err := parseToolCallArguments(call.Function.Arguments) + if err != nil { + return services.ToolCall{}, err + } + return services.ToolCall{ + Tool: toolName, + Params: services.NormalizeToolParams(params), + }, nil +} + +func parseToolCallArguments(args string) (map[string]interface{}, error) { + trimmed := strings.TrimSpace(args) + if trimmed == "" { + return map[string]interface{}{}, nil + } + var params map[string]interface{} + if err := json.Unmarshal([]byte(trimmed), ¶ms); err != nil { + return nil, fmt.Errorf("tool call arguments parse failed: %w", err) + } + if params == nil { + return map[string]interface{}{}, nil + } + return params, nil +} + +func buildAssistantToolCall(callID string, call services.ToolCall) (services.ChatToolCall, error) { + if strings.TrimSpace(callID) == "" { + return services.ChatToolCall{}, fmt.Errorf("tool call id is required") + } + args, err := json.Marshal(call.Params) + if err != nil { + return services.ChatToolCall{}, fmt.Errorf("tool call arguments marshal failed: %w", err) + } + return services.ChatToolCall{ + ID: callID, + Type: "function", + Function: services.ChatToolCallFunction{ + Name: call.Tool, + Arguments: string(args), + }, + }, nil +} + +func buildToolResultContent(result *services.ToolResult) string { + if result == nil { + return "" + } + if strings.TrimSpace(result.Output) != "" { + return result.Output + } + if strings.TrimSpace(result.Error) != "" { + return result.Error + } + return "" +} + func isTransientToolStatusMessage(content string) bool { return strings.HasPrefix(strings.TrimSpace(content), toolStatusPrefix) } diff --git a/internal/tui/core/update_test.go b/internal/tui/core/update_test.go index 912da1f3..6442b649 100644 --- a/internal/tui/core/update_test.go +++ b/internal/tui/core/update_test.go @@ -27,16 +27,16 @@ type fakeChatClient struct { todos []services.Todo } -func (f *fakeChatClient) Chat(_ context.Context, messages []services.Message, model string) (<-chan string, error) { +func (f *fakeChatClient) Chat(_ context.Context, messages []services.Message, model string) (<-chan services.ChatEvent, error) { f.lastMessages = append([]services.Message(nil), messages...) f.lastModel = model if f.chatErr != nil { return nil, f.chatErr } - ch := make(chan string, len(f.chatChunks)) + ch := make(chan services.ChatEvent, len(f.chatChunks)) for _, chunk := range f.chatChunks { - ch <- chunk + ch <- services.ChatEvent{Type: services.ChatEventDelta, Content: chunk} } close(ch) return ch, nil @@ -792,8 +792,8 @@ func TestStreamChunkMsgAppendsContentAndSchedulesNextChunk(t *testing.T) { m.chat.Generating = true m.chat.Messages = []state.Message{{Role: "assistant", Content: ""}} - ch := make(chan string, 1) - ch <- "second" + ch := make(chan services.ChatEvent, 1) + ch <- services.ChatEvent{Type: services.ChatEventDelta, Content: "second"} close(ch) m.streamChan = ch @@ -928,7 +928,7 @@ func TestStreamDoneMsgCompletesWithoutToolCall(t *testing.T) { client := &fakeChatClient{} m := newTestModel(t, client) m.chat.Generating = true - ch := make(chan string) + ch := make(chan services.ChatEvent) close(ch) m.streamChan = ch m.chat.Messages = []state.Message{{Role: "assistant", Content: "done", Streaming: true}} @@ -1016,11 +1016,9 @@ func TestRefreshMemoryMsgIgnoresClientError(t *testing.T) { } } -func TestStreamDoneMsgExecutesToolCallFromAssistantJSON(t *testing.T) { +func TestStreamToolCallMsgExecutesToolCall(t *testing.T) { client := &fakeChatClient{} m := newTestModel(t, client) - m.chat.Generating = true - m.chat.Messages = []state.Message{{Role: "assistant", Content: `{"tool":"read","params":{"path":"README.md"}}`, Streaming: true}} expected := &services.ToolResult{ToolName: "read", Success: true, Output: "ok"} executeToolCall = func(call services.ToolCall) *services.ToolResult { @@ -1033,17 +1031,26 @@ func TestStreamDoneMsgExecutesToolCallFromAssistantJSON(t *testing.T) { return expected } - updated, cmd := m.Update(StreamDoneMsg{}) + updated, cmd := m.Update(StreamToolCallMsg{ + Call: services.ToolCall{ + Tool: "read", + Params: map[string]interface{}{"path": "README.md"}, + }, + CallID: "call-1", + }) got := updated.(Model) if !got.chat.ToolExecuting { t.Fatal("expected tool execution flag to be set") } - if len(got.chat.Messages) != 1 { - t.Fatalf("expected pure tool JSON to be replaced by a single tool status message, got %d messages", len(got.chat.Messages)) + if len(got.chat.Messages) != 2 { + t.Fatalf("expected tool call and tool status messages, got %d messages", len(got.chat.Messages)) + } + if got.chat.Messages[0].Role != "assistant" { + t.Fatalf("expected assistant tool call message, got %+v", got.chat.Messages[0]) } - if !strings.HasPrefix(got.chat.Messages[0].Content, toolStatusPrefix) { - t.Fatalf("expected transient tool status, got %q", got.chat.Messages[0].Content) + if !strings.HasPrefix(got.chat.Messages[1].Content, toolStatusPrefix) { + t.Fatalf("expected transient tool status, got %q", got.chat.Messages[1].Content) } if cmd == nil { t.Fatal("expected tool execution command") @@ -1056,17 +1063,24 @@ func TestStreamDoneMsgExecutesToolCallFromAssistantJSON(t *testing.T) { if resultMsg.Result != expected { t.Fatalf("expected tool result to round-trip, got %+v", resultMsg.Result) } + if resultMsg.CallID != "call-1" { + t.Fatalf("expected call id to round-trip, got %q", resultMsg.CallID) + } } -func TestStreamDoneMsgReturnsToolErrorWhenToolResultIsNil(t *testing.T) { +func TestStreamToolCallMsgReturnsToolErrorWhenToolResultIsNil(t *testing.T) { client := &fakeChatClient{} m := newTestModel(t, client) - m.chat.Generating = true - m.chat.Messages = []state.Message{{Role: "assistant", Content: `{"tool":"read","params":{"path":"README.md"}}`, Streaming: true}} executeToolCall = func(services.ToolCall) *services.ToolResult { return nil } - _, cmd := m.Update(StreamDoneMsg{}) + _, cmd := m.Update(StreamToolCallMsg{ + Call: services.ToolCall{ + Tool: "read", + Params: map[string]interface{}{"path": "README.md"}, + }, + CallID: "call-1", + }) if cmd == nil { t.Fatal("expected tool execution command") } @@ -1083,7 +1097,7 @@ func TestToolResultMsgAddsContextAndRestartsStreaming(t *testing.T) { m.chat.ToolExecuting = true result := &services.ToolResult{ToolName: "read", Success: true, Output: "README"} - updated, cmd := m.Update(ToolResultMsg{Result: result}) + updated, cmd := m.Update(ToolResultMsg{Result: result, CallID: "call-1"}) got := updated.(Model) if got.chat.ToolExecuting { @@ -1092,14 +1106,17 @@ func TestToolResultMsgAddsContextAndRestartsStreaming(t *testing.T) { if !got.chat.Generating { t.Fatal("expected follow-up generation to start") } - if len(got.chat.Messages) != 3 { - t.Fatalf("expected tool context and placeholder messages, got %d", len(got.chat.Messages)) + if len(got.chat.Messages) != 4 { + t.Fatalf("expected tool context, tool message, and placeholder messages, got %d", len(got.chat.Messages)) } if !strings.HasPrefix(got.chat.Messages[1].Content, toolContextPrefix) { t.Fatalf("expected tool context message, got %q", got.chat.Messages[1].Content) } - if got.chat.Messages[2].Role != "assistant" || got.chat.Messages[2].Content != "" { - t.Fatalf("expected assistant placeholder, got %+v", got.chat.Messages[2]) + if got.chat.Messages[2].Role != "tool" || got.chat.Messages[2].ToolCallID != "call-1" { + t.Fatalf("expected tool message with call id, got %+v", got.chat.Messages[2]) + } + if got.chat.Messages[3].Role != "assistant" || got.chat.Messages[3].Content != "" { + t.Fatalf("expected assistant placeholder, got %+v", got.chat.Messages[3]) } if cmd == nil { t.Fatal("expected streaming command") @@ -1119,7 +1136,7 @@ func TestToolErrorMsgAddsErrorContextAndRestartsStreaming(t *testing.T) { m := newTestModel(t, client) m.chat.ToolExecuting = true - updated, cmd := m.Update(ToolErrorMsg{Err: errors.New("tool failed")}) + updated, cmd := m.Update(ToolErrorMsg{Err: errors.New("tool failed"), CallID: "call-1"}) got := updated.(Model) if got.chat.ToolExecuting { @@ -1128,12 +1145,15 @@ func TestToolErrorMsgAddsErrorContextAndRestartsStreaming(t *testing.T) { if !got.chat.Generating { t.Fatal("expected generation restart after tool error") } - if len(got.chat.Messages) != 2 { - t.Fatalf("expected tool error context and placeholder, got %d messages", len(got.chat.Messages)) + if len(got.chat.Messages) != 3 { + t.Fatalf("expected tool error context, tool message, and placeholder, got %d messages", len(got.chat.Messages)) } if !strings.Contains(got.chat.Messages[0].Content, "tool failed") { t.Fatalf("expected tool error context, got %q", got.chat.Messages[0].Content) } + if got.chat.Messages[1].Role != "tool" || got.chat.Messages[1].ToolCallID != "call-1" { + t.Fatalf("expected tool message with call id, got %+v", got.chat.Messages[1]) + } if cmd == nil { t.Fatal("expected follow-up stream command") } @@ -1424,6 +1444,7 @@ func TestApproveCommandWhileToolExecutingKeepsPendingApproval(t *testing.T) { }, ToolType: "Bash", Target: "echo hello", + CallID: "call-1", }, }, } @@ -1440,6 +1461,9 @@ func TestApproveCommandWhileToolExecutingKeepsPendingApproval(t *testing.T) { if got.chat.PendingApproval.Call.Tool != "bash" { t.Fatalf("expected pending tool to stay intact, got %+v", got.chat.PendingApproval.Call) } + if got.chat.PendingApproval.CallID != "call-1" { + t.Fatalf("expected pending call id to stay intact, got %+v", got.chat.PendingApproval.CallID) + } if len(got.chat.Messages) != 1 { t.Fatalf("expected a single assistant warning, got %d", len(got.chat.Messages)) } @@ -1471,6 +1495,7 @@ func TestToolResultMsgSecurityAskStoresPendingApproval(t *testing.T) { "command": "echo hello", }, }, + CallID: "call-1", }) if cmd != nil { t.Fatal("expected no follow-up command while waiting for approval") @@ -1486,6 +1511,9 @@ func TestToolResultMsgSecurityAskStoresPendingApproval(t *testing.T) { if got.chat.PendingApproval.Target != "echo hello" { t.Fatalf("unexpected pending approval target %q", got.chat.PendingApproval.Target) } + if got.chat.PendingApproval.CallID != "call-1" { + t.Fatalf("unexpected pending approval call id %q", got.chat.PendingApproval.CallID) + } if len(got.chat.Messages) != 1 { t.Fatalf("expected one approval prompt message, got %d", len(got.chat.Messages)) } diff --git a/internal/tui/core/view.go b/internal/tui/core/view.go index c7f96ceb..f6b9c593 100644 --- a/internal/tui/core/view.go +++ b/internal/tui/core/view.go @@ -94,14 +94,20 @@ func (m *Model) renderChatContent() string { } func (m Model) toComponentMessages() []components.Message { - messages := make([]components.Message, len(m.chat.Messages)) - for i, msg := range m.chat.Messages { - messages[i] = components.Message{ + messages := make([]components.Message, 0, len(m.chat.Messages)) + for _, msg := range m.chat.Messages { + if msg.Role == "tool" { + continue + } + if msg.Role == "assistant" && strings.TrimSpace(msg.Content) == "" && len(msg.ToolCalls) > 0 { + continue + } + messages = append(messages, components.Message{ Role: msg.Role, Content: displayMessageContent(msg.Role, msg.Content), Timestamp: msg.Timestamp, Streaming: msg.Streaming, - } + }) } return messages } diff --git a/internal/tui/services/api_client.go b/internal/tui/services/api_client.go index e01c4b91..9f32dca6 100644 --- a/internal/tui/services/api_client.go +++ b/internal/tui/services/api_client.go @@ -33,7 +33,7 @@ var ( // ChatClient 定义 TUI 侧依赖的最小聊天与记忆接口。 type ChatClient interface { - Chat(ctx context.Context, messages []Message, model string) (<-chan string, error) + Chat(ctx context.Context, messages []Message, model string) (<-chan ChatEvent, error) GetMemoryStats(ctx context.Context) (*MemoryStats, error) ClearMemory(ctx context.Context) error ClearSessionMemory(ctx context.Context) error @@ -120,16 +120,13 @@ func NewLocalChatClient() (ChatClient, error) { } // Chat 通过本地聊天服务发送消息。 -func (c *localChatClient) Chat(ctx context.Context, messages []Message, model string) (<-chan string, error) { +func (c *localChatClient) Chat(ctx context.Context, messages []Message, model string) (<-chan ChatEvent, error) { chatProvider, err := provider.NewChatProvider(model) if err != nil { return nil, err } - schemaPrompt, err := tools.GlobalSchemaPrompt() - if err != nil { - return nil, err - } - chatSvc := service.NewChatService(c.memorySvc, c.workingSvc, c.todoSvc, c.roleSvc, chatProvider, schemaPrompt) + toolSchemas := tools.BuildToolSchemas(tools.GlobalRegistry.ListDefinitions()) + chatSvc := service.NewChatService(c.memorySvc, c.workingSvc, c.todoSvc, c.roleSvc, chatProvider, toolSchemas) return chatSvc.Send(ctx, &domain.ChatRequest{Messages: messages, Model: model}) } diff --git a/internal/tui/services/runtime_services.go b/internal/tui/services/runtime_services.go index cef89191..1e133020 100644 --- a/internal/tui/services/runtime_services.go +++ b/internal/tui/services/runtime_services.go @@ -13,6 +13,11 @@ import ( type ToolCall = domain.ToolCall type ToolResult = servertools.ToolResult +type ChatEvent = domain.ChatEvent +type ChatEventType = domain.ChatEventType +type ChatToolCall = domain.ChatToolCall +type ChatToolCallFunction = domain.ChatToolCallFunction +type ToolSchema = domain.ToolSchema const ( TypeUserPreference = domain.TypeUserPreference @@ -20,6 +25,10 @@ const ( TypeCodeFact = domain.TypeCodeFact TypeFixRecipe = domain.TypeFixRecipe TypeSessionMemory = domain.TypeSessionMemory + + ChatEventDelta = domain.ChatEventDelta + ChatEventToolCall = domain.ChatEventToolCall + ChatEventDone = domain.ChatEventDone ) var ( diff --git a/internal/tui/state/chat_state.go b/internal/tui/state/chat_state.go index 213f5f5d..f461223b 100644 --- a/internal/tui/state/chat_state.go +++ b/internal/tui/state/chat_state.go @@ -7,16 +7,19 @@ import ( ) type Message struct { - Role string - Content string - Timestamp time.Time - Streaming bool + Role string + Content string + ToolCalls []services.ChatToolCall + ToolCallID string + Timestamp time.Time + Streaming bool } type PendingApproval struct { Call services.ToolCall ToolType string Target string + CallID string } type ChatState struct { From 2aa01f0c45b2c068924a389b6765c5bb2f3e9faa Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Wed, 25 Mar 2026 20:55:33 +0800 Subject: [PATCH 4/9] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E5=B7=A5?= =?UTF-8?q?=E5=85=B7schema=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- configs/persona.txt | 7 +++---- configs/persona.txt.example | 7 +++---- ...EFINED_ARCHITECTURE.md => tui-refined-architecture.md} | 8 ++++---- 4 files changed, 11 insertions(+), 13 deletions(-) rename docs/{TUI_REFINED_ARCHITECTURE.md => tui-refined-architecture.md} (88%) diff --git a/README.md b/README.md index e32e5861..7e163572 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ persona: - `configs/persona.txt` 现在只负责行为约束和回复风格,不再维护任何具体工具参数说明。 - 模型可见的工具参数、必填项、默认值和枚举约束,统一来源于 `internal/server/infra/tools/*.go` 中各工具的 `Definition()`。 -- 聊天服务会在运行时把渲染后的 tool schema 注入到 system context,因此更新 schema 定义后,模型上下文会自动同步。 +- 聊天服务会在运行时把渲染后的 tool schema 作为 `tools` 参数传给模型,更新 schema 定义后会自动同步到工具调用协议。 ## Memory 设计 diff --git a/configs/persona.txt b/configs/persona.txt index 8e10391d..37260486 100644 --- a/configs/persona.txt +++ b/configs/persona.txt @@ -7,7 +7,7 @@ Behavior: - Reply in Chinese by default unless the user asks for another language. Safety and tool use: -- Treat the injected tool schema as the single source of truth for tool names, parameter names, parameter types, required fields, default values, and allowed values. +- Treat the provided tool schemas as the single source of truth for tool names, parameter names, parameter types, required fields, default values, and allowed values. - Do not restate, duplicate, or invent tool parameters outside the schema. - Evaluate risk before using tools with side effects, especially bash, file writes, and network requests. - If the security policy returns deny, refuse clearly and explain why. @@ -17,6 +17,5 @@ Safety and tool use: - Keep file operations inside the current workspace unless the user explicitly requests otherwise and the policy allows it. Tool call protocol: -- When a tool is needed, emit exactly one JSON object in the form {"tool":"","params":{...}}. -- Do not output any extra text before or after a tool call. -- After emitting the JSON tool call, stop and wait for the tool result. +- Use the official tool calling mechanism provided by the system; do not inline tool JSON in plain text. +- When a tool is called, wait for the tool result before continuing the response. diff --git a/configs/persona.txt.example b/configs/persona.txt.example index 8e10391d..37260486 100644 --- a/configs/persona.txt.example +++ b/configs/persona.txt.example @@ -7,7 +7,7 @@ Behavior: - Reply in Chinese by default unless the user asks for another language. Safety and tool use: -- Treat the injected tool schema as the single source of truth for tool names, parameter names, parameter types, required fields, default values, and allowed values. +- Treat the provided tool schemas as the single source of truth for tool names, parameter names, parameter types, required fields, default values, and allowed values. - Do not restate, duplicate, or invent tool parameters outside the schema. - Evaluate risk before using tools with side effects, especially bash, file writes, and network requests. - If the security policy returns deny, refuse clearly and explain why. @@ -17,6 +17,5 @@ Safety and tool use: - Keep file operations inside the current workspace unless the user explicitly requests otherwise and the policy allows it. Tool call protocol: -- When a tool is needed, emit exactly one JSON object in the form {"tool":"","params":{...}}. -- Do not output any extra text before or after a tool call. -- After emitting the JSON tool call, stop and wait for the tool result. +- Use the official tool calling mechanism provided by the system; do not inline tool JSON in plain text. +- When a tool is called, wait for the tool result before continuing the response. diff --git a/docs/TUI_REFINED_ARCHITECTURE.md b/docs/tui-refined-architecture.md similarity index 88% rename from docs/TUI_REFINED_ARCHITECTURE.md rename to docs/tui-refined-architecture.md index d6c1c99d..0dace9b6 100644 --- a/docs/TUI_REFINED_ARCHITECTURE.md +++ b/docs/tui-refined-architecture.md @@ -61,10 +61,10 @@ 1. 用户在输入框编辑内容,`core/update.go` 处理按键并更新 `textarea` 状态。 2. 按下 `F5` 或 `F8` 后,`handleSubmit()` 将用户消息写入 `chat_state`,然后触发 `streamResponse(...)`。 3. `services.ChatClient.Chat(...)` 启动本地聊天服务,流式返回模型输出。 -4. 聊天服务会把角色提示、记忆上下文、任务上下文,以及由 `internal/server/infra/tools/*.go` 的 `Definition()` 渲染出的 tool schema 一并注入到 system context。 -5. `StreamChunkMsg` 持续追加 assistant 内容;`StreamDoneMsg` 在流结束时检查最后一条 assistant 消息是否是工具调用 JSON。 -6. 若检测到 `{"tool":"...","params":{...}}`,TUI 会通过 `services.ExecuteToolCall(...)` 执行工具,并把工具结果重新注入为 system 上下文。 -7. 模型基于新的上下文继续生成,直到得到最终自然语言回复。 +4. 聊天服务会把角色提示、记忆上下文和任务上下文注入到 system context,同时把工具 schema 作为 `tools` 参数发送给模型。 +5. provider 流式返回文本增量与 `tool_calls`,TUI 分别转成 `StreamChunkMsg` / `StreamToolCallMsg`。 +6. 收到 `StreamToolCallMsg` 后,TUI 执行工具,并把工具结果以 `role=tool`、`tool_call_id` 形式回传给模型。 +7. 模型基于工具结果继续生成,直到得到最终自然语言回复。 --- From 25f566bb15403c48203585b62a06376269ba9425 Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Wed, 25 Mar 2026 21:07:08 +0800 Subject: [PATCH 5/9] =?UTF-8?q?test:=20=E5=A2=9E=E5=8A=A0tool=5Fcalls?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E8=81=9A=E5=90=88=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../provider/chat_provider_stream_test.go | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 internal/server/infra/provider/chat_provider_stream_test.go diff --git a/internal/server/infra/provider/chat_provider_stream_test.go b/internal/server/infra/provider/chat_provider_stream_test.go new file mode 100644 index 00000000..ea51d48f --- /dev/null +++ b/internal/server/infra/provider/chat_provider_stream_test.go @@ -0,0 +1,196 @@ +package provider + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "go-llm-demo/internal/server/domain" +) + +func TestChatProviderToolCallsAggregated(t *testing.T) { + t.Parallel() + + var requestBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + t.Fatalf("decode request: %v", err) + } + + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, buildStreamLine("ok", []toolCallDelta{ + { + Index: 0, + ID: "call_1", + Type: "function", + Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Name: "read", + Arguments: `{"path":"README`, + }, + }, + }, "")) + _, _ = io.WriteString(w, buildStreamLine("", []toolCallDelta{ + { + Index: 0, + Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Arguments: `.md"}`, + }, + }, + }, "tool_calls")) + _, _ = io.WriteString(w, "data: [DONE]\n") + })) + t.Cleanup(server.Close) + + tools := []domain.ToolSchema{ + { + Type: "function", + Function: domain.ToolFunctionSchema{ + Name: "read", + Parameters: domain.ToolParametersSchema{ + Type: "object", + Properties: map[string]domain.ToolParamSchema{ + "path": {Type: "string"}, + }, + Required: []string{"path"}, + }, + }, + }, + } + + provider := &ChatCompletionProvider{ + APIKey: "test", + BaseURL: server.URL, + Model: "test-model", + } + + ch, err := provider.Chat(context.Background(), []domain.Message{{Role: "user", Content: "hi"}}, tools) + if err != nil { + t.Fatalf("chat failed: %v", err) + } + + events := collectChatEvents(ch) + if got := requestBody["tool_choice"]; got != "auto" { + t.Fatalf("expected tool_choice auto, got %#v", got) + } + if _, ok := requestBody["tools"]; !ok { + t.Fatal("expected tools in request body") + } + + if len(events) != 2 { + t.Fatalf("expected 2 events, got %d", len(events)) + } + if events[0].Type != domain.ChatEventDelta || events[0].Content != "ok" { + t.Fatalf("expected delta event, got %+v", events[0]) + } + if events[1].Type != domain.ChatEventToolCall || events[1].ToolCall == nil { + t.Fatalf("expected tool call event, got %+v", events[1]) + } + if events[1].ToolCall.ID != "call_1" { + t.Fatalf("expected tool call id call_1, got %q", events[1].ToolCall.ID) + } + if events[1].ToolCall.Function.Name != "read" { + t.Fatalf("expected function read, got %q", events[1].ToolCall.Function.Name) + } + if events[1].ToolCall.Function.Arguments != `{"path":"README.md"}` { + t.Fatalf("expected merged arguments, got %q", events[1].ToolCall.Function.Arguments) + } +} + +func TestChatProviderToolCallsOrdered(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, buildStreamLine("", []toolCallDelta{ + { + Index: 1, + ID: "call_2", + Type: "function", + Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Name: "write", + Arguments: `{"path":"b.txt"}`, + }, + }, + }, "")) + _, _ = io.WriteString(w, buildStreamLine("", []toolCallDelta{ + { + Index: 0, + ID: "call_1", + Type: "function", + Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Name: "read", + Arguments: `{"path":"a.txt"}`, + }, + }, + }, "tool_calls")) + _, _ = io.WriteString(w, "data: [DONE]\n") + })) + t.Cleanup(server.Close) + + provider := &ChatCompletionProvider{ + APIKey: "test", + BaseURL: server.URL, + Model: "test-model", + } + + ch, err := provider.Chat(context.Background(), []domain.Message{{Role: "user", Content: "hi"}}, nil) + if err != nil { + t.Fatalf("chat failed: %v", err) + } + + events := collectChatEvents(ch) + if len(events) != 2 { + t.Fatalf("expected 2 events, got %d", len(events)) + } + + if events[0].ToolCall == nil || events[1].ToolCall == nil { + t.Fatalf("expected tool call events, got %+v", events) + } + if events[0].ToolCall.Function.Name != "read" || events[0].ToolCall.ID != "call_1" { + t.Fatalf("expected first call to be read call_1, got %+v", events[0].ToolCall) + } + if events[1].ToolCall.Function.Name != "write" || events[1].ToolCall.ID != "call_2" { + t.Fatalf("expected second call to be write call_2, got %+v", events[1].ToolCall) + } +} + +func buildStreamLine(content string, deltas []toolCallDelta, finishReason string) string { + payload := map[string]any{ + "choices": []any{ + map[string]any{ + "delta": map[string]any{ + "content": content, + "tool_calls": deltas, + }, + "finish_reason": finishReason, + }, + }, + } + data, _ := json.Marshal(payload) + return "data: " + string(data) + "\n" +} + +func collectChatEvents(ch <-chan domain.ChatEvent) []domain.ChatEvent { + events := []domain.ChatEvent{} + for event := range ch { + events = append(events, event) + } + return events +} From c5fad142b237a3d4cb92fa8c410cf5cba81b4fe2 Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Thu, 26 Mar 2026 00:33:22 +0800 Subject: [PATCH 6/9] =?UTF-8?q?test:=20=E4=BF=AE=E5=A4=8Dprovider=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/server/infra/provider/registry_test.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/server/infra/provider/registry_test.go b/internal/server/infra/provider/registry_test.go index c83028d5..e070f8e0 100644 --- a/internal/server/infra/provider/registry_test.go +++ b/internal/server/infra/provider/registry_test.go @@ -123,14 +123,14 @@ func TestChatCompletionProviderChatStreamsFallbackMessageOnMalformedChunk(t *tes Model: "test-model", } - stream, err := p.Chat(context.Background(), []domain.Message{{Role: "user", Content: "hi"}}) + stream, err := p.Chat(context.Background(), []domain.Message{{Role: "user", Content: "hi"}}, nil) if err != nil { t.Fatalf("expected stream request to succeed, got error: %v", err) } var output strings.Builder for chunk := range stream { - output.WriteString(chunk) + output.WriteString(chunk.Content) } got := output.String() @@ -154,14 +154,14 @@ func TestChatCompletionProviderChatStreamsFallbackMessageOnUnexpectedEOF(t *test Model: "test-model", } - stream, err := p.Chat(context.Background(), []domain.Message{{Role: "user", Content: "hi"}}) + stream, err := p.Chat(context.Background(), []domain.Message{{Role: "user", Content: "hi"}}, nil) if err != nil { t.Fatalf("expected stream request to succeed, got error: %v", err) } var output strings.Builder for chunk := range stream { - output.WriteString(chunk) + output.WriteString(chunk.Content) } got := output.String() @@ -174,12 +174,12 @@ func TestChatCompletionProviderChatStreamsFallbackMessageOnUnexpectedEOF(t *test } func TestEmitStreamErrorMessageIgnoresNilError(t *testing.T) { - out := make(chan string, 1) + out := make(chan domain.ChatEvent, 1) emitStreamErrorMessage(context.Background(), out, nil) select { case got := <-out: - t.Fatalf("expected no message for nil error, got %q", got) + t.Fatalf("expected no message for nil error, got %q", got.Content) default: } } @@ -188,7 +188,7 @@ func TestEmitStreamErrorMessageReturnsWhenContextCanceled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - out := make(chan string) + out := make(chan domain.ChatEvent) done := make(chan struct{}) go func() { From 99d2d9114f856fb1b9892e8da280cb5b86cefdb0 Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Thu, 26 Mar 2026 12:28:22 +0800 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20=E8=BF=81=E7=A7=BB=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=8F=90=E7=A4=BA=E8=AF=8D=E4=B8=8E=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- configs/app_config.go | 2 +- configs/app_config_test.go | 16 +++---- configs/persona.go | 23 ++------- configs/persona.txt | 21 -------- configs/persona.txt.example | 21 -------- configs/prompt.md | 95 +++++++++++++++++++++++++++++++++++++ 6 files changed, 108 insertions(+), 70 deletions(-) delete mode 100644 configs/persona.txt delete mode 100644 configs/persona.txt.example create mode 100644 configs/prompt.md diff --git a/configs/app_config.go b/configs/app_config.go index b5681684..a880605e 100644 --- a/configs/app_config.go +++ b/configs/app_config.go @@ -68,7 +68,7 @@ func DefaultAppConfig() *AppConfiguration { cfg.History.PersistSessionState = true cfg.History.WorkspaceStateDir = "./data/workspaces" cfg.History.ResumeLastSession = true - cfg.Persona.FilePath = DefaultPersonaFilePath + cfg.Persona.FilePath = DefaultPromptFilePath return cfg } diff --git a/configs/app_config_test.go b/configs/app_config_test.go index 82d58c91..7ac151bb 100644 --- a/configs/app_config_test.go +++ b/configs/app_config_test.go @@ -106,7 +106,7 @@ history: workspace_state_dir: "./data/workspaces" resume_last_session: true persona: - file_path: "./configs/persona.txt" + file_path: "./configs/prompt.md" `) if err := os.WriteFile(path, content, 0o644); err != nil { t.Fatalf("write config: %v", err) @@ -201,14 +201,14 @@ func validConfig() *AppConfiguration { cfg.History.PersistSessionState = true cfg.History.WorkspaceStateDir = "./data/workspaces" cfg.History.ResumeLastSession = true - cfg.Persona.FilePath = DefaultPersonaFilePath + cfg.Persona.FilePath = DefaultPromptFilePath return cfg } func TestDefaultAppConfigUsesCheckedInPersonaPath(t *testing.T) { cfg := DefaultAppConfig() - if cfg.Persona.FilePath != DefaultPersonaFilePath { - t.Fatalf("expected default persona path %q, got %q", DefaultPersonaFilePath, cfg.Persona.FilePath) + if cfg.Persona.FilePath != DefaultPromptFilePath { + t.Fatalf("expected default prompt path %q, got %q", DefaultPromptFilePath, cfg.Persona.FilePath) } } @@ -222,8 +222,8 @@ func TestResolvePersonaFilePathFallsBackToCheckedInFile(t *testing.T) { if err := os.MkdirAll(configsDir, 0o755); err != nil { t.Fatalf("mkdir configs: %v", err) } - if err := os.WriteFile(filepath.Join(configsDir, "persona.txt"), []byte("persona"), 0o644); err != nil { - t.Fatalf("write persona: %v", err) + if err := os.WriteFile(filepath.Join(configsDir, "prompt.md"), []byte("persona"), 0o644); err != nil { + t.Fatalf("write prompt: %v", err) } if err := os.Chdir(tmpDir); err != nil { t.Fatalf("chdir temp dir: %v", err) @@ -233,8 +233,8 @@ func TestResolvePersonaFilePathFallsBackToCheckedInFile(t *testing.T) { }() resolved := ResolvePersonaFilePath("./persona.txt") - if resolved != DefaultPersonaFilePath { - t.Fatalf("expected legacy persona path to resolve to %q, got %q", DefaultPersonaFilePath, resolved) + if resolved != DefaultPromptFilePath { + t.Fatalf("expected legacy persona path to resolve to %q, got %q", DefaultPromptFilePath, resolved) } if _, err := os.Stat(resolved); err != nil { t.Fatalf("expected resolved persona file to exist: %v", err) diff --git a/configs/persona.go b/configs/persona.go index ab6758e3..16625cf7 100644 --- a/configs/persona.go +++ b/configs/persona.go @@ -7,31 +7,16 @@ import ( ) const ( - DefaultPersonaFilePath = "./configs/persona.txt" - legacyPersonaFilePath = "./persona.txt" + DefaultPromptFilePath = "./configs/prompt.md" ) func ResolvePersonaFilePath(path string) string { trimmed := strings.TrimSpace(path) if trimmed == "" { - return "" + return DefaultPromptFilePath } - candidates := []string{trimmed} - if trimmed == legacyPersonaFilePath || trimmed == "persona.txt" { - candidates = append(candidates, DefaultPersonaFilePath, "configs/persona.txt") - } - - for _, candidate := range candidates { - if candidate == "" { - continue - } - if _, err := os.Stat(candidate); err == nil { - return candidate - } - } - - return trimmed + return DefaultPromptFilePath } func LoadPersonaPrompt(path string) (string, string, error) { @@ -42,7 +27,7 @@ func LoadPersonaPrompt(path string) (string, string, error) { data, err := os.ReadFile(resolvedPath) if err != nil { - return "", resolvedPath, fmt.Errorf("read persona file %q: %w", resolvedPath, err) + return "", resolvedPath, fmt.Errorf("read prompt file %q: %w", resolvedPath, err) } return strings.TrimSpace(string(data)), resolvedPath, nil diff --git a/configs/persona.txt b/configs/persona.txt deleted file mode 100644 index 37260486..00000000 --- a/configs/persona.txt +++ /dev/null @@ -1,21 +0,0 @@ -You are NeoCode, a professional, reliable, and concise AI coding assistant. - -Behavior: -- Prefer concrete, executable engineering solutions. -- Keep answers clear and concise, and provide steps only when they help. -- State assumptions explicitly when information is uncertain. -- Reply in Chinese by default unless the user asks for another language. - -Safety and tool use: -- Treat the provided tool schemas as the single source of truth for tool names, parameter names, parameter types, required fields, default values, and allowed values. -- Do not restate, duplicate, or invent tool parameters outside the schema. -- Evaluate risk before using tools with side effects, especially bash, file writes, and network requests. -- If the security policy returns deny, refuse clearly and explain why. -- If the security policy returns ask, ask the user for approval before continuing. -- Before running bash, explain the goal, scope, and key impact. Prefer read-only, reversible, least-privilege commands. -- Never run destructive or unsafe commands such as unbounded deletion, system damage, or malicious download-and-execute flows. -- Keep file operations inside the current workspace unless the user explicitly requests otherwise and the policy allows it. - -Tool call protocol: -- Use the official tool calling mechanism provided by the system; do not inline tool JSON in plain text. -- When a tool is called, wait for the tool result before continuing the response. diff --git a/configs/persona.txt.example b/configs/persona.txt.example deleted file mode 100644 index 37260486..00000000 --- a/configs/persona.txt.example +++ /dev/null @@ -1,21 +0,0 @@ -You are NeoCode, a professional, reliable, and concise AI coding assistant. - -Behavior: -- Prefer concrete, executable engineering solutions. -- Keep answers clear and concise, and provide steps only when they help. -- State assumptions explicitly when information is uncertain. -- Reply in Chinese by default unless the user asks for another language. - -Safety and tool use: -- Treat the provided tool schemas as the single source of truth for tool names, parameter names, parameter types, required fields, default values, and allowed values. -- Do not restate, duplicate, or invent tool parameters outside the schema. -- Evaluate risk before using tools with side effects, especially bash, file writes, and network requests. -- If the security policy returns deny, refuse clearly and explain why. -- If the security policy returns ask, ask the user for approval before continuing. -- Before running bash, explain the goal, scope, and key impact. Prefer read-only, reversible, least-privilege commands. -- Never run destructive or unsafe commands such as unbounded deletion, system damage, or malicious download-and-execute flows. -- Keep file operations inside the current workspace unless the user explicitly requests otherwise and the policy allows it. - -Tool call protocol: -- Use the official tool calling mechanism provided by the system; do not inline tool JSON in plain text. -- When a tool is called, wait for the tool result before continuing the response. diff --git a/configs/prompt.md b/configs/prompt.md new file mode 100644 index 00000000..1ee60930 --- /dev/null +++ b/configs/prompt.md @@ -0,0 +1,95 @@ +# Neo-code System Prompt (Managed) + +You are a coding agent running in the Neo-code CLI, a terminal-based coding assistant. Neo-code is an open source project led by OpenAI. You are expected to be precise, safe, and helpful. + +Your capabilities: + +- Receive user prompts and other context provided by the harness, such as files in the workspace. +- Communicate with the user by streaming thinking and responses, and by making and updating plans. +- Emit function calls to run terminal commands and apply patches. Depending on how this specific run is configured, you can request that these function calls be escalated to the user for approval before running. + +Within this context, Neo-code refers to the open-source agentic coding interface (not a language model name). + +# How you work + +## Personality +Your default personality and tone is concise, direct, and friendly. You communicate efficiently, always keeping the user clearly informed about ongoing actions without unnecessary detail. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work. + +# AGENTS.md spec +- Repos often contain AGENTS.md files. These files can appear anywhere within the repository. +- These files are a way for humans to give you (the agent) instructions or tips for working within the container. +- Some examples might be: coding conventions, info about how code is organized, or instructions for how to run or test code. +- Instructions in AGENTS.md files: + - The scope of an AGENTS.md file is the entire directory tree rooted at the folder that contains it. + - For every file you touch in the final patch, you must obey instructions in any AGENTS.md file whose scope includes that file. + - Instructions about code style, structure, naming, etc. apply only within the AGENTS.md file scope unless it states otherwise. + - More deeply nested AGENTS.md files take precedence in case of conflicting instructions. + - Direct system/developer/user instructions (as part of a prompt) take precedence over AGENTS.md instructions. +- The contents of the AGENTS.md file at the repo root and any directories from the CWD up to the root are included with the system prompt and do not need to be re-read. When working in a subdirectory of CWD, or a directory outside the CWD, check for any AGENTS.md files that may be applicable. + +## Responsiveness + +### Preamble messages +Before making tool calls, send a brief preamble to the user explaining what you are about to do. When sending preamble messages, follow these principles and examples: + +- Logically group related actions: if you are about to run several related commands, describe them together in one preamble rather than sending a separate note for each. +- Keep it concise: be no more than 1-2 sentences, focused on immediate, tangible next steps (8-12 words for quick updates). +- Build on prior context: if this is not your first tool call, connect the dots with what has been done. +- Keep your tone light, friendly and curious: add small touches of personality in preambles. +- Exception: avoid adding a preamble for every trivial read (for example, reading a single file) unless it is part of a larger grouped action. + +Examples: +- "I have the context; now checking the API route definitions." +- "Next, I will patch the config and update the related tests." +- "I am about to scaffold the CLI commands and helper functions." +- "Ok cool, so I have wrapped my head around the repo. Now digging into the API routes." +- "Config looks tidy. Next up is patching helpers to keep things in sync." +- "Finished poking at the DB gateway. I will now chase down error handling." +- "Alright, build pipeline order is interesting. Checking how it reports failures." +- "Spotted a clever caching util; now hunting where it gets used." + +## Planning +You have access to a plan tool which tracks steps and progress and renders them to the user. Plans should be short, actionable, and used only when tasks are multi-step or ambiguous. Do not use plans for simple or single-step queries that can be answered immediately. + +When using a plan: +- Mark steps completed as you go, and keep exactly one step in progress. +- Update the plan if new steps are discovered. +- Do not repeat the full plan in normal responses; summarize key progress instead. + +Use a plan when: +- The task is non-trivial and will require multiple actions. +- There are logical phases or dependencies where sequencing matters. +- The work has ambiguity that benefits from outlining high-level goals. +- The user asked to use a plan. + +### Example plans + +High-quality: +1. Add CLI entry with file args +2. Parse Markdown via CommonMark library +3. Apply semantic HTML template +4. Handle code blocks, images, links +5. Add error handling for invalid files + +Low-quality: +1. Create CLI tool +2. Add Markdown parser +3. Convert to HTML + +## Task execution +You are a coding agent. Continue until the query is resolved before ending the turn. Fix root causes where possible, avoid unrelated changes, and follow repository conventions. + +## Validation +When tests exist, run the most relevant ones first, then broaden as needed. Do not fix unrelated failures. + +## Ambition vs. precision +Be creative for greenfield work, but precise and minimal in existing codebases. + +## Tool use and safety +- Use tool schemas as the single source of truth for tool names and parameters. +- Explain intent before running commands with side effects. +- Prefer read-only, reversible actions when possible. +- Do not run destructive or unsafe commands. + +## Output format +Keep responses concise and structured when helpful. Use clear headings and short lists. Avoid excessive formatting or repetition unless requested. From 6009ed56640f4360d55b44c44602a8f364f16513 Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Thu, 26 Mar 2026 12:30:19 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0TUI=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D=E5=8A=A0=E8=BD=BD=E4=B8=8E=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/tui/main.go | 4 ++-- cmd/tui/main_test.go | 44 ++++++++++++++++++++++---------------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/cmd/tui/main.go b/cmd/tui/main.go index d23e4e07..ad37a6fb 100644 --- a/cmd/tui/main.go +++ b/cmd/tui/main.go @@ -105,9 +105,9 @@ func runWithDeps(workspaceFlag string, deps runDeps) error { persona, personaPath, err := deps.loadPersonaPrompt(configs.GlobalAppConfig.Persona.FilePath) if err != nil { - fmt.Fprintf(deps.stderr, "警告: 人设加载失败: %v\n", err) + fmt.Fprintf(deps.stderr, "警告: 系统提示词加载失败: %v\n", err) } else if personaPath != "" && strings.TrimSpace(configs.GlobalAppConfig.Persona.FilePath) != personaPath { - fmt.Fprintf(deps.stderr, "提示: 人设已从 %s 回退加载\n", personaPath) + fmt.Fprintf(deps.stderr, "提示: 系统提示词已从 %s 回退加载\n", personaPath) } historyTurns := configs.GlobalAppConfig.History.ShortTermTurns diff --git a/cmd/tui/main_test.go b/cmd/tui/main_test.go index 368663bf..b12b2eff 100644 --- a/cmd/tui/main_test.go +++ b/cmd/tui/main_test.go @@ -79,26 +79,26 @@ func TestLoadDotEnvTrimsQuotedValuesAndSkipsEmptyKeys(t *testing.T) { } } -func TestLoadPersonaPromptReturnsTrimmedContent(t *testing.T) { +func TestLoadPromptReturnsTrimmedContent(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, "persona.txt") - if err := os.WriteFile(path, []byte("\n hello persona \n"), 0o644); err != nil { - t.Fatalf("write persona file: %v", err) + path := filepath.Join(dir, "prompt.md") + if err := os.WriteFile(path, []byte("\n hello prompt \n"), 0o644); err != nil { + t.Fatalf("write prompt file: %v", err) } - if got := loadPersonaPrompt(path); got != "hello persona" { - t.Fatalf("expected trimmed persona prompt, got %q", got) + if got := loadPersonaPrompt(path); got != "hello prompt" { + t.Fatalf("expected trimmed prompt, got %q", got) } } -func TestLoadPersonaPromptReturnsEmptyForMissingFile(t *testing.T) { +func TestLoadPromptReturnsEmptyForMissingFile(t *testing.T) { missing := filepath.Join(t.TempDir(), "missing.txt") if got := loadPersonaPrompt(missing); got != "" { t.Fatalf("expected empty string for missing file, got %q", got) } } -func TestLoadPersonaPromptReturnsEmptyForBlankPath(t *testing.T) { +func TestLoadPromptReturnsEmptyForBlankPath(t *testing.T) { if got := loadPersonaPrompt(" "); got != "" { t.Fatalf("expected empty string for blank path, got %q", got) } @@ -239,7 +239,7 @@ func TestRunWithDepsReturnsLoadAppConfigError(t *testing.T) { } } -func TestRunWithDepsPrintsPersonaFallbackHintAndRunsProgram(t *testing.T) { +func TestRunWithDepsPrintsPromptFallbackHintAndRunsProgram(t *testing.T) { origGlobalConfig := configs.GlobalAppConfig t.Cleanup(func() { configs.GlobalAppConfig = origGlobalConfig }) @@ -263,14 +263,14 @@ func TestRunWithDepsPrintsPersonaFallbackHintAndRunsProgram(t *testing.T) { }, loadPersonaPrompt: func(path string) (string, string, error) { if path != "./persona.txt" { - t.Fatalf("expected configured persona path, got %q", path) + t.Fatalf("expected configured prompt path, got %q", path) } - return "persona text", "./configs/persona.txt", nil + return "prompt text", "./configs/prompt.md", nil }, newProgram: func(persona string, historyTurns int, configPath, workspaceRoot string) (programRunner, error) { newProgramCalled = true - if persona != "persona text" { - t.Fatalf("unexpected persona %q", persona) + if persona != "prompt text" { + t.Fatalf("unexpected prompt %q", persona) } if historyTurns != cfg.History.ShortTermTurns { t.Fatalf("unexpected history turns %d", historyTurns) @@ -287,12 +287,12 @@ func TestRunWithDepsPrintsPersonaFallbackHintAndRunsProgram(t *testing.T) { if !newProgramCalled || !program.called { t.Fatal("expected program to be created and run") } - if !strings.Contains(stderr.String(), "./configs/persona.txt") { - t.Fatalf("expected fallback persona hint, got %q", stderr.String()) + if !strings.Contains(stderr.String(), "./configs/prompt.md") { + t.Fatalf("expected fallback prompt hint, got %q", stderr.String()) } } -func TestRunWithDepsContinuesWhenPersonaLoadFails(t *testing.T) { +func TestRunWithDepsContinuesWhenPromptLoadFails(t *testing.T) { origGlobalConfig := configs.GlobalAppConfig t.Cleanup(func() { configs.GlobalAppConfig = origGlobalConfig }) @@ -312,11 +312,11 @@ func TestRunWithDepsContinuesWhenPersonaLoadFails(t *testing.T) { return nil }, loadPersonaPrompt: func(string) (string, string, error) { - return "", "", errors.New("persona failed") + return "", "", errors.New("prompt failed") }, newProgram: func(persona string, historyTurns int, configPath, workspaceRoot string) (programRunner, error) { if persona != "" { - t.Fatalf("expected empty persona on load failure, got %q", persona) + t.Fatalf("expected empty prompt on load failure, got %q", persona) } return program, nil }, @@ -327,8 +327,8 @@ func TestRunWithDepsContinuesWhenPersonaLoadFails(t *testing.T) { if !program.called { t.Fatal("expected program to still run") } - if !strings.Contains(stderr.String(), "persona failed") { - t.Fatalf("expected persona warning, got %q", stderr.String()) + if !strings.Contains(stderr.String(), "prompt failed") { + t.Fatalf("expected prompt warning, got %q", stderr.String()) } } @@ -349,7 +349,7 @@ func TestRunWithDepsReturnsNewProgramError(t *testing.T) { configs.GlobalAppConfig = cfg return nil }, - loadPersonaPrompt: func(string) (string, string, error) { return "persona", "", nil }, + loadPersonaPrompt: func(string) (string, string, error) { return "prompt", "", nil }, newProgram: func(string, int, string, string) (programRunner, error) { return nil, errors.New("new program failed") }, }) if err == nil || !strings.Contains(err.Error(), "new program failed") { @@ -404,7 +404,7 @@ func TestRunWithDepsHappyPathCallsUTF8Hook(t *testing.T) { configs.GlobalAppConfig = cfg return nil }, - loadPersonaPrompt: func(string) (string, string, error) { return "persona", "", nil }, + loadPersonaPrompt: func(string) (string, string, error) { return "prompt", "", nil }, newProgram: func(string, int, string, string) (programRunner, error) { return program, nil }, }) if err != nil { From 0dc01eaa1db6f7387f0a8631dc9110b3bc41f46c Mon Sep 17 00:00:00 2001 From: Cai_Tang Date: Thu, 26 Mar 2026 12:32:35 +0800 Subject: [PATCH 9/9] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E7=A4=BA=E4=BE=8B=E4=B8=8E=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 ++++---- config.example.yaml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7e163572..dfba3fd3 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ history: resume_last_session: true persona: - file_path: "./persona.txt" + file_path: "./configs/prompt.md" ``` 说明: @@ -148,11 +148,11 @@ persona: - `history.persist_session_state`:是否按 workspace 持久化当前工作现场 - `history.workspace_state_dir`:workspace 会话状态文件保存目录 - `history.resume_last_session`:启动时是否展示恢复摘要 -- `persona.file_path`:启动时加载的人设文件 +- `persona.file_path`:启动时加载的系统提示词文件(默认 `configs/prompt.md`) ## Tool Schema 维护说明 -- `configs/persona.txt` 现在只负责行为约束和回复风格,不再维护任何具体工具参数说明。 +- `configs/prompt.md` 保存系统提示词,不建议直接修改;工具参数以 schema 为唯一来源。 - 模型可见的工具参数、必填项、默认值和枚举约束,统一来源于 `internal/server/infra/tools/*.go` 中各工具的 `Definition()`。 - 聊天服务会在运行时把渲染后的 tool schema 作为 `tools` 参数传给模型,更新 schema 定义后会自动同步到工具调用协议。 @@ -200,7 +200,7 @@ go run ./cmd/server - `config.example.yaml`:配置模板 - `data/memory_rules.json`:长期结构化记忆文件 - `data/workspaces//session_state.json`:按工作区保存的当前工作现场 -- `persona.txt`:人设内容 +- `prompt.md`:系统提示词 ## 安全与本地文件 diff --git a/config.example.yaml b/config.example.yaml index b96c2618..d5c20579 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -29,7 +29,7 @@ history: resume_last_session: true persona: - file_path: "./configs/persona.txt" + file_path: "./configs/prompt.md" # Notes: # - Switching provider with /provider resets ai.model to that provider's default model.