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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er
contextBuilder,
)
runtimeSvc.SetSessionAssetStore(sessionStore)
runtimeSvc.SetUserInputPreparer(agentruntime.NewSessionInputPreparer(sessionStore, sessionStore))
runtimeSvc.SetSkillsRegistry(buildSkillsRegistry(ctx, loader.BaseDir()))
runtimeSvc.SetAutoCompactThresholdResolver(runtimeAutoCompactThresholdResolverFunc(
func(ctx context.Context, cfg config.Config) (int, error) {
Expand Down
10 changes: 5 additions & 5 deletions internal/config/loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1050,12 +1050,12 @@ func TestLoadCustomProvidersReadDirAndStatErrors(t *testing.T) {
t.Fatalf("WriteFile() error = %v", err)
}

_, err := loadCustomProviders(baseDir)
if err == nil {
t.Fatal("expected read providers dir error")
providers, err := loadCustomProviders(baseDir)
if err != nil {
t.Fatalf("expected read providers dir fallback, got %v", err)
}
if !strings.Contains(err.Error(), "read providers dir") {
t.Fatalf("expected read providers dir error, got %v", err)
if len(providers) != 0 {
t.Fatalf("expected empty providers on read fallback, got %d", len(providers))
}
})

Expand Down
2 changes: 1 addition & 1 deletion internal/config/provider_loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func loadCustomProviders(baseDir string) ([]ProviderConfig, error) {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("config: read providers dir: %w", err)
return nil, nil
}

sort.Slice(entries, func(i, j int) bool {
Expand Down
56 changes: 56 additions & 0 deletions internal/provider/openaicompat/chatcompletions/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,62 @@ func TestNewAndBuildRequest(t *testing.T) {
t.Fatalf("unexpected tools: %+v", payload.Tools)
}

toolSchemaWithTopLevelCombinator := map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{"type": "string"},
},
"oneOf": []any{
map[string]any{"required": []string{"action"}},
},
}
sanitizedPayload, err := BuildRequest(context.Background(), testCfg("https://api.example.com/v1", "gpt-4.1", "test-key"), providertypes.GenerateRequest{
Messages: []providertypes.Message{
{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}},
},
Tools: []providertypes.ToolSpec{{
Name: "todo_write",
Description: "write todos",
Schema: toolSchemaWithTopLevelCombinator,
}},
})
if err != nil {
t.Fatalf("BuildRequest() sanitize schema error = %v", err)
}
gotSchema := sanitizedPayload.Tools[0].Function.Parameters
if gotSchema["type"] != "object" {
t.Fatalf("expected sanitized schema type object, got %+v", gotSchema["type"])
}
if _, ok := gotSchema["oneOf"]; !ok {
t.Fatalf("expected top-level oneOf to be preserved, got %+v", gotSchema)
}
if _, ok := toolSchemaWithTopLevelCombinator["oneOf"]; !ok {
t.Fatalf("expected original schema not to be mutated")
}

downgradedPayload, err := BuildRequest(context.Background(), testCfg("https://api.example.com/v1", "gpt-4.1", "test-key"), providertypes.GenerateRequest{
Messages: []providertypes.Message{
{Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}},
},
Tools: []providertypes.ToolSpec{{
Name: "non_object_schema",
Description: "schema root is string",
Schema: map[string]any{
"type": "string",
},
}},
})
if err != nil {
t.Fatalf("BuildRequest() downgrade schema error = %v", err)
}
downgradedSchema := downgradedPayload.Tools[0].Function.Parameters
if downgradedSchema["type"] != "object" {
t.Fatalf("expected downgraded schema type object, got %+v", downgradedSchema["type"])
}
if _, ok := downgradedSchema["x-neocode-schema-downgraded"]; ok {
t.Fatalf("expected no custom downgrade marker in outbound schema, got %+v", downgradedSchema)
}

withSessionAsset, err := BuildRequest(context.Background(), testCfg("https://api.example.com/v1", "gpt-4.1", "test-key"), providertypes.GenerateRequest{
Messages: []providertypes.Message{
{
Expand Down
36 changes: 35 additions & 1 deletion internal/provider/openaicompat/chatcompletions/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func BuildRequest(ctx context.Context, cfg provider.RuntimeConfig, req providert
Function: FunctionDefinition{
Name: spec.Name,
Description: spec.Description,
Parameters: spec.Schema,
Parameters: normalizeToolSchemaForOpenAI(spec.Schema),
},
}
payload.Tools = append(payload.Tools, def)
Expand All @@ -77,6 +77,40 @@ func BuildRequest(ctx context.Context, cfg provider.RuntimeConfig, req providert
return payload, nil
}

// normalizeToolSchemaForOpenAI 归一化工具参数 schema,避免修改调用方原始结构并尽量保持语义。
// 仅在缺失 schema 或明显非法(非 object 顶层)时做最小兼容降级,不再删除顶层组合约束关键字。
func normalizeToolSchemaForOpenAI(schema map[string]any) map[string]any {
normalized := cloneSchemaTopLevel(schema)
if len(normalized) == 0 {
return map[string]any{
"type": "object",
"properties": map[string]any{},
}
}

typeName, _ := normalized["type"].(string)
if strings.TrimSpace(strings.ToLower(typeName)) != "object" {
normalized["type"] = "object"
}

if _, ok := normalized["properties"].(map[string]any); !ok {
normalized["properties"] = map[string]any{}
}
return normalized
}

// cloneSchemaTopLevel 复制 schema 顶层 map,避免归一化阶段修改调用方原始结构。
func cloneSchemaTopLevel(schema map[string]any) map[string]any {
if len(schema) == 0 {
return map[string]any{}
}
cloned := make(map[string]any, len(schema))
for key, value := range schema {
cloned[key] = value
}
return cloned
}

// ToOpenAIMessage 将通用 Message 转换为 OpenAI 协议消息格式。
func ToOpenAIMessage(ctx context.Context, message providertypes.Message, assetReader providertypes.SessionAssetReader) (Message, error) {
msg, _, err := toOpenAIMessageWithBudget(ctx, message, assetReader, maxSessionAssetsTotalBytes)
Expand Down
28 changes: 28 additions & 0 deletions internal/runtime/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,28 @@ type TodoEventPayload struct {
Reason string `json:"reason,omitempty"`
}

// InputNormalizedPayload 描述输入归一化完成后的摘要信息。
type InputNormalizedPayload struct {
TextLength int `json:"text_length"`
ImageCount int `json:"image_count"`
}

// AssetSavedPayload 描述单个附件成功保存后的结果。
type AssetSavedPayload struct {
Index int `json:"index"`
Path string `json:"path,omitempty"`
AssetID string `json:"asset_id"`
MimeType string `json:"mime_type,omitempty"`
Size int64 `json:"size,omitempty"`
}

// AssetSaveFailedPayload 描述单个附件保存失败的结构化信息。
type AssetSaveFailedPayload struct {
Index int `json:"index"`
Path string `json:"path,omitempty"`
Message string `json:"message"`
}

const (
// EventUserMessage 表示用户消息已写入会话。
EventUserMessage EventType = "user_message"
Expand Down Expand Up @@ -143,6 +165,12 @@ const (
EventTodoConflict EventType = "todo_conflict"
// EventTodoSummaryInjected 表示本轮上下文注入了 Todo 摘要。
EventTodoSummaryInjected EventType = "todo_summary_injected"
// EventInputNormalized 表示用户输入已完成归一化。
EventInputNormalized EventType = "input_normalized"
// EventAssetSaved 表示本轮用户输入附件已完成持久化。
EventAssetSaved EventType = "asset_saved"
// EventAssetSaveFailed 表示本轮用户输入附件持久化失败。
EventAssetSaveFailed EventType = "asset_save_failed"
)

// TokenUsagePayload 承载单轮 token 用量统计。
Expand Down
165 changes: 165 additions & 0 deletions internal/runtime/input_prepare.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package runtime

import (
"context"
"errors"
"fmt"
"strings"
"time"

agentsession "neo-code/internal/session"
)

const prepareEventEmitTimeout = 200 * time.Millisecond

// NewSessionInputPreparer 创建基于 session 子层实现的输入归一化适配器。
func NewSessionInputPreparer(store agentsession.Store, assetStore agentsession.AssetStore) UserInputPreparer {
return sessionInputPreparer{
preparer: agentsession.NewInputPreparer(store, assetStore),
}
}

// PrepareUserInput 负责在运行前执行输入归一化编排,并发出最小可观测事件。
// Submit 作为运行时提交入口,统一串联输入归一化与执行,避免上层手动编排两段调用。
func (s *Service) Submit(ctx context.Context, input PrepareInput) error {
prepared, err := s.PrepareUserInput(ctx, input)
if err != nil {
return err
}
return s.Run(ctx, prepared)
}

func (s *Service) PrepareUserInput(ctx context.Context, input PrepareInput) (UserInput, error) {
if err := ctx.Err(); err != nil {
return UserInput{}, err
}
if s == nil {
return UserInput{}, errors.New("runtime: service is nil")
}
if s.userInputPreparer == nil {
err := errors.New("runtime: user input preparer is not configured")
_ = s.emitPrepareFailure(ctx, input, err)
return UserInput{}, err
}

defaultWorkdir := ""
if s.configManager != nil {
defaultWorkdir = strings.TrimSpace(s.configManager.Get().Workdir)
}

prepared, err := s.userInputPreparer.Prepare(ctx, input, defaultWorkdir)
if err != nil {
_ = s.emitPrepareFailure(ctx, input, err)
return UserInput{}, err
}

runID := strings.TrimSpace(input.RunID)
_ = s.emitPrepareEvent(ctx, EventInputNormalized, runID, prepared.UserInput.SessionID, InputNormalizedPayload{
TextLength: len([]rune(strings.TrimSpace(input.Text))),
ImageCount: len(input.Images),
})
for index, asset := range prepared.SavedAssets {
path := ""
if index >= 0 && index < len(input.Images) {
path = strings.TrimSpace(input.Images[index].Path)
}
_ = s.emitPrepareEvent(ctx, EventAssetSaved, runID, prepared.UserInput.SessionID, AssetSavedPayload{
Index: index,
Path: path,
AssetID: asset.ID,
MimeType: asset.MimeType,
Size: asset.Size,
})
}

return prepared.UserInput, nil
}

// emitPrepareFailure 统一发送输入归一化阶段的失败事件,避免前置副作用变成黑箱。
func (s *Service) emitPrepareFailure(ctx context.Context, input PrepareInput, err error) error {
if s == nil {
return nil
}

runID := strings.TrimSpace(input.RunID)
sessionID := strings.TrimSpace(input.SessionID)

var saveErr *agentsession.AssetSaveError
if errors.As(err, &saveErr) {
if session := strings.TrimSpace(saveErr.SessionID); session != "" {
sessionID = session
}
return s.emitPrepareEvent(ctx, EventAssetSaveFailed, runID, sessionID, AssetSaveFailedPayload{
Index: saveErr.Index,
Path: strings.TrimSpace(saveErr.Path),
Message: strings.TrimSpace(saveErr.Error()),
})
}
return s.emitPrepareEvent(ctx, EventError, runID, sessionID, strings.TrimSpace(err.Error()))
}

// emitPrepareEvent 在输入归一化阶段使用限时上下文发事件,避免通道拥塞导致提交链路卡死。
func (s *Service) emitPrepareEvent(ctx context.Context, kind EventType, runID string, sessionID string, payload any) error {
emitCtx := ctx
cancel := func() {}
if _, hasDeadline := emitCtx.Deadline(); !hasDeadline {
emitCtx, cancel = context.WithTimeout(emitCtx, prepareEventEmitTimeout)
}
defer cancel()

if err := s.emit(emitCtx, kind, runID, sessionID, payload); err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return nil
}
return err
}
return nil
}

type sessionInputPreparer struct {
preparer *agentsession.InputPreparer
}

// Prepare 将 runtime 输入 DTO 映射到 session 子层并返回标准 UserInput 结果。
func (p sessionInputPreparer) Prepare(
ctx context.Context,
input PrepareInput,
defaultWorkdir string,
) (PreparedInputResult, error) {
if p.preparer == nil {
return PreparedInputResult{}, errors.New("runtime: session input preparer is nil")
}

sessionImages := make([]agentsession.PrepareImageInput, 0, len(input.Images))
for _, image := range input.Images {
sessionImages = append(sessionImages, agentsession.PrepareImageInput{
Path: strings.TrimSpace(image.Path),
MimeType: strings.TrimSpace(image.MimeType),
})
}

prepared, err := p.preparer.Prepare(ctx, agentsession.PrepareInput{
SessionID: strings.TrimSpace(input.SessionID),
Text: input.Text,
Images: sessionImages,
DefaultWorkdir: strings.TrimSpace(defaultWorkdir),
RequestedWorkdir: strings.TrimSpace(input.Workdir),
})
if err != nil {
return PreparedInputResult{}, err
}

if len(prepared.Parts) == 0 {
return PreparedInputResult{}, fmt.Errorf("runtime: prepared parts is empty")
}

return PreparedInputResult{
UserInput: UserInput{
SessionID: strings.TrimSpace(prepared.SessionID),
RunID: strings.TrimSpace(input.RunID),
Parts: prepared.Parts,
Workdir: strings.TrimSpace(prepared.Workdir),
},
SavedAssets: append([]agentsession.AssetMeta(nil), prepared.SavedAssets...),
}, nil
}
Loading
Loading