Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
17c2f51
feat(skills): 打通 session/runtime/context 接入并修正降级语义
wynxing Apr 15, 2026
1332e9b
fix(runtime): normalize skill events and dedupe missing notifications
xgopilot Apr 15, 2026
a291625
test: add coverage for session/runtime/context skill branches
xgopilot Apr 15, 2026
8129290
Merge pull request #13 from Yumiue/test_campact
wynxing Apr 15, 2026
97da88f
feat(runtime):增加工具调用重复循环熔断机制
phantom5099 Apr 15, 2026
f8bc410
fix(runtime): 修复重复循环配置校验回归并补齐熔断回归测试
xgopilot Apr 15, 2026
9744a2c
fix(config/runtime): 统一 MaxRepeatCycleStreak 三处守卫语义一致性并补齐测试
xgopilot Apr 15, 2026
07fa747
Merge pull request #16 from phantom5099/fork-pr-303-1776228657
phantom5099 Apr 15, 2026
dd4be72
test(runtime): 提升重复调用熔断相关覆盖率
xgopilot Apr 15, 2026
f60a3a6
Merge pull request #305 from Yumiue/main
phantom5099 Apr 15, 2026
b073953
fix(context): 修复 metadata-only 工具结果语义丢失
wynxing Apr 15, 2026
ad20555
fix(runtime): avoid duplicate metadata sanitize in tool message norma…
xgopilot Apr 15, 2026
cbd9160
Merge pull request #307 from Yumiue/codex/fix-metadata-tool-result-pr…
phantom5099 Apr 15, 2026
0dab2c2
Merge remote-tracking branch 'refs/remotes/origin/main'
phantom5099 Apr 15, 2026
afce7e3
fix(runtime): align repeat-cycle breaker semantics and tests
xgopilot Apr 15, 2026
de9db53
Merge pull request #17 from phantom5099/fork-pr-303-1776263436
phantom5099 Apr 15, 2026
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
13 changes: 13 additions & 0 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package app
import (
"context"
"log"
"path/filepath"
"strings"
"time"

Expand All @@ -19,6 +20,7 @@ import (
agentruntime "neo-code/internal/runtime"
"neo-code/internal/security"
agentsession "neo-code/internal/session"
"neo-code/internal/skills"
"neo-code/internal/tools"
"neo-code/internal/tools/bash"
"neo-code/internal/tools/filesystem"
Expand Down Expand Up @@ -165,6 +167,7 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er
providerRegistry,
contextBuilder,
)
runtimeSvc.SetSkillsRegistry(buildSkillsRegistry(ctx, loader.BaseDir()))

// 注入记忆提取钩子:当 AutoExtract 启用且 memoSvc 可用时,ReAct 循环完成后异步提取记忆。
if memoSvc != nil && cfg.Memo.AutoExtract {
Expand Down Expand Up @@ -259,6 +262,16 @@ func buildToolRegistry(cfg config.Config) (*tools.Registry, func() error, error)
return toolRegistry, mcpRegistry.Close, nil
}

// buildSkillsRegistry 负责以最小代价初始化本地 skills registry,refresh 失败时仅记录日志并保留 registry 实例。
func buildSkillsRegistry(ctx context.Context, baseDir string) skills.Registry {
root := filepath.Join(baseDir, "skills")
registry := skills.NewRegistry(skills.NewLocalLoader(root))
if err := registry.Refresh(ctx); err != nil {
log.Printf("skills: initialize registry from %s failed: %v", root, err)
}
return registry
}

// buildMCPAgentExposureRules 将配置层的 agent 过滤规则转换为 tools/mcp 层输入。
func buildMCPAgentExposureRules(configs []config.MCPAgentExposureConfig) []mcp.AgentExposureRule {
if len(configs) == 0 {
Expand Down
107 changes: 107 additions & 0 deletions internal/app/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import (
"neo-code/internal/provider"
providertypes "neo-code/internal/provider/types"
agentruntime "neo-code/internal/runtime"
agentsession "neo-code/internal/session"
"neo-code/internal/skills"
"neo-code/internal/tools"
"neo-code/internal/tools/mcp"
"neo-code/internal/tui"
Expand Down Expand Up @@ -736,6 +738,111 @@ func TestBuildRuntimeUsesWorkdirOverride(t *testing.T) {
}
}

func TestBuildRuntimeSucceedsWhenSkillsRootMissing(t *testing.T) {
disableBuiltinProviderAPIKeys(t)

home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

bundle, err := BuildRuntime(context.Background(), BootstrapOptions{})
if err != nil {
t.Fatalf("BuildRuntime() error = %v", err)
}
if bundle.Runtime == nil {
t.Fatalf("expected runtime bundle to be created")
}

runtimeWithSkills, ok := bundle.Runtime.(interface {
ActivateSessionSkill(ctx context.Context, sessionID string, skillID string) error
})
if !ok {
t.Fatalf("expected runtime to expose ActivateSessionSkill")
}

store := agentsession.NewStore(bundle.ConfigManager.BaseDir(), bundle.Config.Workdir)
session := agentsession.New("missing root session")
if err := store.Save(context.Background(), &session); err != nil {
t.Fatalf("save session: %v", err)
}

err = runtimeWithSkills.ActivateSessionSkill(context.Background(), session.ID, "missing")
if !errors.Is(err, skills.ErrSkillNotFound) {
t.Fatalf("expected ErrSkillNotFound with empty catalog, got %v", err)
}
}

func TestBuildRuntimeInjectsSkillsRegistryWhenRootExists(t *testing.T) {
disableBuiltinProviderAPIKeys(t)

home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

skillsRoot := filepath.Join(home, ".neocode", "skills", "go-review")
if err := os.MkdirAll(skillsRoot, 0o755); err != nil {
t.Fatalf("mkdir skills root: %v", err)
}
if err := os.WriteFile(filepath.Join(skillsRoot, "SKILL.md"), []byte(strings.Join([]string{
"---",
"id: go-review",
"name: go-review",
"---",
"",
"# Go Review",
"",
"Review code carefully.",
}, "\n")), 0o644); err != nil {
t.Fatalf("write SKILL.md: %v", err)
}

bundle, err := BuildRuntime(context.Background(), BootstrapOptions{})
if err != nil {
t.Fatalf("BuildRuntime() error = %v", err)
}

store := agentsession.NewStore(bundle.ConfigManager.BaseDir(), bundle.Config.Workdir)
session := agentsession.New("skill session")
if err := store.Save(context.Background(), &session); err != nil {
t.Fatalf("save session: %v", err)
}

runtimeWithSkills, ok := bundle.Runtime.(interface {
ActivateSessionSkill(ctx context.Context, sessionID string, skillID string) error
})
if !ok {
t.Fatalf("expected runtime to expose ActivateSessionSkill")
}
if err := runtimeWithSkills.ActivateSessionSkill(context.Background(), session.ID, "go-review"); err != nil {
t.Fatalf("ActivateSessionSkill() error = %v", err)
}

loaded, err := store.Load(context.Background(), session.ID)
if err != nil {
t.Fatalf("load session: %v", err)
}
if got := loaded.ActiveSkillIDs(); len(got) != 1 || got[0] != "go-review" {
t.Fatalf("expected activated skill persisted through injected registry, got %+v", got)
}
}

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

baseDir := t.TempDir()
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()

registry := buildSkillsRegistry(canceledCtx, baseDir)
if registry == nil {
t.Fatalf("expected non-nil registry even when refresh fails")
}
_, _, err := registry.Get(context.Background(), "missing")
if !errors.Is(err, skills.ErrSkillNotFound) {
t.Fatalf("expected empty catalog behavior, got %v", err)
}
}

func TestBuildRuntimeRejectsInvalidWorkdirOverride(t *testing.T) {
disableBuiltinProviderAPIKeys(t)

Expand Down
3 changes: 2 additions & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1593,7 +1593,8 @@ func TestValidateSnapshotPropagatesCompactError(t *testing.T) {
},
},
Runtime: RuntimeConfig{
MaxNoProgressStreak: 3,
MaxNoProgressStreak: 3,
MaxRepeatCycleStreak: 3,
},
Context: ContextConfig{
Compact: CompactConfig{
Expand Down
15 changes: 12 additions & 3 deletions internal/config/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,21 @@ import (
)

const (
DefaultMaxNoProgressStreak = 3
DefaultMaxNoProgressStreak = 3
DefaultMaxRepeatCycleStreak = 3
)

// RuntimeConfig 定义 runtime 层的可调参数。
type RuntimeConfig struct {
MaxNoProgressStreak int `yaml:"max_no_progress_streak,omitempty"`
MaxNoProgressStreak int `yaml:"max_no_progress_streak,omitempty"`
MaxRepeatCycleStreak int `yaml:"max_repeat_cycle_streak,omitempty"`
}

// defaultRuntimeConfig 返回 runtime 配置的静态默认值。
func defaultRuntimeConfig() RuntimeConfig {
return RuntimeConfig{
MaxNoProgressStreak: DefaultMaxNoProgressStreak,
MaxNoProgressStreak: DefaultMaxNoProgressStreak,
MaxRepeatCycleStreak: DefaultMaxRepeatCycleStreak,
}
}

Expand All @@ -33,12 +36,18 @@ func (c *RuntimeConfig) ApplyDefaults(defaults RuntimeConfig) {
if c.MaxNoProgressStreak <= 0 {
c.MaxNoProgressStreak = defaults.MaxNoProgressStreak
}
if c.MaxRepeatCycleStreak <= 0 {
c.MaxRepeatCycleStreak = defaults.MaxRepeatCycleStreak
}
}

// Validate 校验 runtime 配置是否满足最小约束。
func (c RuntimeConfig) Validate() error {
if c.MaxNoProgressStreak <= 0 {
return errors.New("max_no_progress_streak must be greater than 0")
}
if c.MaxRepeatCycleStreak <= 0 {
Comment thread
fennoai[bot] marked this conversation as resolved.
return errors.New("max_repeat_cycle_streak must be greater than 0")
}
return nil
}
31 changes: 26 additions & 5 deletions internal/config/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,44 @@ import "testing"
func TestRuntimeConfigClone(t *testing.T) {
t.Parallel()

cfg := RuntimeConfig{MaxNoProgressStreak: 7}
cfg := RuntimeConfig{MaxNoProgressStreak: 7, MaxRepeatCycleStreak: 4}
cloned := cfg.Clone()
if cloned.MaxNoProgressStreak != 7 {
t.Fatalf("expected cloned MaxNoProgressStreak=7, got %d", cloned.MaxNoProgressStreak)
}
if cloned.MaxRepeatCycleStreak != 4 {
t.Fatalf("expected cloned MaxRepeatCycleStreak=4, got %d", cloned.MaxRepeatCycleStreak)
}
}

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

defaults := RuntimeConfig{MaxNoProgressStreak: 3}
defaults := RuntimeConfig{MaxNoProgressStreak: 3, MaxRepeatCycleStreak: 5}

cfg := RuntimeConfig{MaxNoProgressStreak: 0}
cfg := RuntimeConfig{MaxNoProgressStreak: 0, MaxRepeatCycleStreak: 0}
cfg.ApplyDefaults(defaults)
if cfg.MaxNoProgressStreak != 3 {
t.Fatalf("expected defaulted MaxNoProgressStreak=3, got %d", cfg.MaxNoProgressStreak)
}
if cfg.MaxRepeatCycleStreak != 5 {
t.Fatalf("expected defaulted MaxRepeatCycleStreak=5, got %d", cfg.MaxRepeatCycleStreak)
}

cfg = RuntimeConfig{MaxNoProgressStreak: 5}
cfg = RuntimeConfig{MaxNoProgressStreak: 5, MaxRepeatCycleStreak: 8}
cfg.ApplyDefaults(defaults)
if cfg.MaxNoProgressStreak != 5 {
t.Fatalf("expected existing MaxNoProgressStreak=5 to be preserved, got %d", cfg.MaxNoProgressStreak)
}
if cfg.MaxRepeatCycleStreak != 8 {
t.Fatalf("expected existing MaxRepeatCycleStreak=8 to be preserved, got %d", cfg.MaxRepeatCycleStreak)
}

cfg = RuntimeConfig{MaxNoProgressStreak: 2, MaxRepeatCycleStreak: -1}
cfg.ApplyDefaults(defaults)
if cfg.MaxRepeatCycleStreak != 5 {
t.Fatalf("expected negative MaxRepeatCycleStreak=-1 to be replaced by default=5, got %d", cfg.MaxRepeatCycleStreak)
}

var nilCfg *RuntimeConfig
nilCfg.ApplyDefaults(defaults)
Expand All @@ -36,7 +51,7 @@ func TestRuntimeConfigApplyDefaults(t *testing.T) {
func TestRuntimeConfigValidate(t *testing.T) {
t.Parallel()

if err := (RuntimeConfig{MaxNoProgressStreak: 1}).Validate(); err != nil {
if err := (RuntimeConfig{MaxNoProgressStreak: 1, MaxRepeatCycleStreak: 1}).Validate(); err != nil {
t.Fatalf("expected valid config, got %v", err)
}

Expand All @@ -45,4 +60,10 @@ func TestRuntimeConfigValidate(t *testing.T) {
t.Fatalf("expected validation error for MaxNoProgressStreak=%d", bad)
}
}

for _, bad := range []int{0, -1, -99} {
if err := (RuntimeConfig{MaxNoProgressStreak: 1, MaxRepeatCycleStreak: bad}).Validate(); err == nil {
t.Fatalf("expected validation error for MaxRepeatCycleStreak=%d", bad)
}
}
}
2 changes: 2 additions & 0 deletions internal/context/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func NewBuilderWithToolPolicies(policies MicroCompactPolicySource) Builder {
corePromptSource{},
&projectRulesSource{},
taskStateSource{},
skillPromptSource{},
systemSource,
},
trimPolicy: spanMessageTrimPolicy{},
Expand All @@ -42,6 +43,7 @@ func NewBuilderWithMemo(policies MicroCompactPolicySource, memoSource SectionSou
corePromptSource{},
&projectRulesSource{},
taskStateSource{},
skillPromptSource{},
}
if memoSource != nil {
sources = append(sources, memoSource)
Expand Down
42 changes: 42 additions & 0 deletions internal/context/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -777,3 +777,45 @@ func TestProjectToolMessagesForModelKeepsBuilderProjectionBehavior(t *testing.T)
t.Fatal("expected source messages to remain unchanged")
}
}

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

builder := NewBuilder()
result, err := builder.Build(stdcontext.Background(), BuildInput{
Messages: []providertypes.Message{
{Role: providertypes.RoleUser, Content: "inspect README"},
{
Role: providertypes.RoleAssistant,
ToolCalls: []providertypes.ToolCall{
{ID: "call-1", Name: "filesystem_read_file", Arguments: `{"path":"README.md"}`},
},
},
{
Role: providertypes.RoleTool,
ToolCallID: "call-1",
Content: " ",
ToolMetadata: map[string]string{"tool_name": "filesystem_read_file", "path": "README.md"},
},
},
Metadata: testMetadata(t.TempDir()),
})
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if len(result.Messages) != 3 {
t.Fatalf("len(result.Messages) = %d, want 3", len(result.Messages))
}
toolMessage := result.Messages[2]
if toolMessage.Role != providertypes.RoleTool {
t.Fatalf("expected tool message at index 2, got %+v", toolMessage)
}
if !strings.Contains(toolMessage.Content, "tool result") ||
!strings.Contains(toolMessage.Content, "tool: filesystem_read_file") ||
!strings.Contains(toolMessage.Content, "meta.path: README.md") {
t.Fatalf("expected metadata-only tool result to be projected, got %q", toolMessage.Content)
}
if toolMessage.ToolMetadata != nil {
t.Fatalf("expected projected tool metadata to be cleared, got %#v", toolMessage.ToolMetadata)
}
}
5 changes: 4 additions & 1 deletion internal/context/projection.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,10 @@ func isInjectableToolMessage(message providertypes.Message) bool {
return false
}
content := strings.TrimSpace(message.Content)
return content != "" && content != microCompactClearedMessage
if content == microCompactClearedMessage {
return false
}
return content != "" || len(message.ToolMetadata) > 0
}

// recentWindowMessageBudget 计算 recent window 可保留的消息总数硬上限,避免窗口体积失控。
Expand Down
Loading
Loading