From ede70338e78a8b7a6451b0374ff634e6b331dedc Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Tue, 14 Apr 2026 17:55:10 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat(runtime):=E5=A2=9E=E5=8A=A0=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E7=9B=91=E6=8E=A7=E4=B8=8E=E6=AD=BB=E5=BE=AA=E7=8E=AF?= =?UTF-8?q?=E6=8B=A6=E6=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 3 +- docs/context-compact.md | 8 +- docs/runtime-provider-event-flow.md | 9 +- internal/config/provider.go | 2 +- internal/runtime/compact.go | 2 +- internal/runtime/controlplane/decider.go | 38 +++ internal/runtime/controlplane/decider_test.go | 68 ++++++ internal/runtime/controlplane/envelope.go | 4 + internal/runtime/controlplane/phase.go | 13 + internal/runtime/controlplane/progress.go | 53 +++++ .../runtime/controlplane/progress_test.go | 42 ++++ internal/runtime/controlplane/stop_reason.go | 15 ++ internal/runtime/errors.go | 48 ---- internal/runtime/event_emitter.go | 71 ++++++ internal/runtime/events.go | 59 ++++- internal/runtime/memo.go | 23 -- internal/runtime/permission.go | 2 +- internal/runtime/permission_test.go | 8 +- internal/runtime/run.go | 73 ++++-- internal/runtime/run_lifecycle.go | 146 ++++++++++++ internal/runtime/run_termination_test.go | 49 ++++ internal/runtime/runtime.go | 150 ------------ .../runtime/runtime_internal_helpers_test.go | 14 +- internal/runtime/runtime_progress_test.go | 86 +++++++ .../runtime_remaining_branches_test.go | 4 +- internal/runtime/runtime_test.go | 222 ++++++++++++++---- internal/runtime/session_mutation.go | 5 +- internal/runtime/session_scheduler.go | 138 +++++++++++ internal/runtime/state.go | 36 ++- internal/runtime/toolexec.go | 115 ++++++--- internal/tui/core/app/update.go | 64 ++++- .../tui/core/app/update_permission_test.go | 8 +- 32 files changed, 1190 insertions(+), 388 deletions(-) create mode 100644 internal/runtime/controlplane/decider.go create mode 100644 internal/runtime/controlplane/decider_test.go create mode 100644 internal/runtime/controlplane/envelope.go create mode 100644 internal/runtime/controlplane/phase.go create mode 100644 internal/runtime/controlplane/progress.go create mode 100644 internal/runtime/controlplane/progress_test.go create mode 100644 internal/runtime/controlplane/stop_reason.go delete mode 100644 internal/runtime/errors.go create mode 100644 internal/runtime/event_emitter.go create mode 100644 internal/runtime/run_lifecycle.go create mode 100644 internal/runtime/run_termination_test.go create mode 100644 internal/runtime/runtime_progress_test.go create mode 100644 internal/runtime/session_scheduler.go diff --git a/AGENTS.md b/AGENTS.md index b0c7a887..db75b9a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,8 @@ ## 2. 最高优先级规则 - 不要为了“可能兼容旧版本”破坏当前架构;若新设计已确定,优先直接切换到新实现。 +- 不允许过度设计、过度包装 +- 项目中可能存在语义不清的地方,必须要谨慎分析 - 不要跨层直连;新功能默认沿 `TUI -> Runtime -> Provider / Tool Manager` 主链路设计。 - 不要把模型厂商差异泄漏到 `runtime`、`tui` 或上层调用方。 - 不要在 `runtime` 或 `tui` 里直接写工具执行逻辑;所有可被模型调用的能力必须进入 `internal/tools`。 @@ -43,7 +45,6 @@ ## 4. AI 修改代码时的执行流程 - 先定位改动所属模块,再检查是否会破坏职责边界。 - 优先做最小闭环改动,避免无关重构。 -- 项目中可能存在语义不清的地方,必须要谨慎分析 - 如果新增能力会被模型调用,先设计 `tools` 抽象,再接入 `runtime`。 - 如果改动涉及 provider 协议,差异收敛在 `internal/provider` 内,不向外扩散特定厂商字段。 - 如果改动涉及配置,统一经由 `ConfigManager` 或现有配置管理入口处理。 diff --git a/docs/context-compact.md b/docs/context-compact.md index 20a3ed8e..be431119 100644 --- a/docs/context-compact.md +++ b/docs/context-compact.md @@ -61,7 +61,7 @@ context: 优先复用会话记录的 `provider` / `model`,缺失时回退到当前配置。 6. summary generator 调用模型生成完整 `task_state` 与 display summary。 7. runner 校验 display summary 结构与长度,必要时截断,并写入 `task_state.last_updated_at`。 -8. compact 成功时回写 `session.TaskState` 与会话消息并发出 `compact_done`;失败时发出 `compact_error`。 +8. compact 成功时回写 `session.TaskState` 与会话消息并发出 `compact_applied`;失败时发出 `compact_error`。 其中 `reactive` mode 在 context 包内与 `manual` 复用同一条压缩管线: @@ -74,7 +74,7 @@ context: 1. 识别 provider 归一化后的 typed error,必要时回退到错误文本匹配。 2. 触发一次 `compact.Run(mode=reactive)`。 -3. 继续复用 `compact_start`、`compact_done`、`compact_error` 事件,并通过 `trigger_mode=reactive` 区分来源。 +3. 继续复用 `compact_start`、`compact_applied`、`compact_error` 事件,并通过 `trigger_mode=reactive` 区分来源。 4. 每次 `Run()` 最多只执行一次 reactive 重试,避免无限循环。 ## 生成协议 @@ -138,10 +138,10 @@ constraints: compact 相关 runtime 事件包括: - `compact_start` -- `compact_done` +- `compact_applied` - `compact_error` -`compact_done` payload 包含: +`compact_applied` payload 包含: - `applied` - `before_chars` diff --git a/docs/runtime-provider-event-flow.md b/docs/runtime-provider-event-flow.md index 2c87a234..b070ed94 100644 --- a/docs/runtime-provider-event-flow.md +++ b/docs/runtime-provider-event-flow.md @@ -2,7 +2,7 @@ ## Runtime 事件类型 -当前 runtime 对外暴露一组小而稳定的事件: +当前 runtime 对外暴露一组小而稳定的事件(1A 硬切后不再保留旧事件镜像): - `user_message` - `agent_chunk` @@ -11,14 +11,13 @@ - `tool_start` - `tool_chunk` - `tool_result` -- `run_canceled` -- `error` +- `stop_reason_decided` - `provider_retry` -- `permission_request` +- `permission_requested` - `permission_resolved` - `token_usage` - `compact_start` -- `compact_done` +- `compact_applied` - `compact_error` ## ReAct 主循环 diff --git a/internal/config/provider.go b/internal/config/provider.go index 3f033c17..4c0d036c 100644 --- a/internal/config/provider.go +++ b/internal/config/provider.go @@ -183,7 +183,7 @@ func (p ResolvedProviderConfig) ToRuntimeConfig() provider.RuntimeConfig { const ( OpenAIName = "openai" OpenAIDefaultBaseURL = "https://api.openai.com/v1" - OpenAIDefaultModel = "gpt-4.1" + OpenAIDefaultModel = "gpt-5.4" OpenAIDefaultAPIKeyEnv = "OPENAI_API_KEY" GeminiName = "gemini" diff --git a/internal/runtime/compact.go b/internal/runtime/compact.go index 8c9133b0..8af6a7c2 100644 --- a/internal/runtime/compact.go +++ b/internal/runtime/compact.go @@ -154,7 +154,7 @@ func (s *Service) runCompactForSession( } } - s.emit(ctx, EventCompactDone, runID, session.ID, fromCompactResult(result)) + s.emit(ctx, EventCompactApplied, runID, session.ID, fromCompactResult(result)) return session, result, nil } diff --git a/internal/runtime/controlplane/decider.go b/internal/runtime/controlplane/decider.go new file mode 100644 index 00000000..641bc6cb --- /dev/null +++ b/internal/runtime/controlplane/decider.go @@ -0,0 +1,38 @@ +package controlplane + +import ( + "context" + "errors" + "strings" +) + +// StopInput 汇总停止决议所需的信号(可多信号并存,由 DecideStopReason 按优先级表决)。 +type StopInput struct { + ContextCanceled bool + MaxLoopsReached bool + RunError error + Success bool +} + +// DecideStopReason 按固定优先级返回唯一 StopReason:取消 > 达到轮数上限 > 错误 > 成功。 +func DecideStopReason(in StopInput) (StopReason, string) { + if in.ContextCanceled { + return StopReasonCanceled, "" + } + if in.MaxLoopsReached { + if in.RunError != nil { + return StopReasonMaxLoops, strings.TrimSpace(in.RunError.Error()) + } + return StopReasonMaxLoops, "runtime: max loop reached" + } + if in.RunError != nil { + if errors.Is(in.RunError, context.Canceled) { + return StopReasonCanceled, "" + } + return StopReasonError, strings.TrimSpace(in.RunError.Error()) + } + if in.Success { + return StopReasonSuccess, "" + } + return StopReasonError, "runtime: stop reason undetermined" +} diff --git a/internal/runtime/controlplane/decider_test.go b/internal/runtime/controlplane/decider_test.go new file mode 100644 index 00000000..942eb565 --- /dev/null +++ b/internal/runtime/controlplane/decider_test.go @@ -0,0 +1,68 @@ +package controlplane + +import ( + "context" + "errors" + "testing" +) + +func TestDecideStopReasonPriority(t *testing.T) { + t.Parallel() + + errSample := errors.New("boom") + cases := []struct { + name string + in StopInput + reason StopReason + }{ + { + name: "canceled_wins_over_max_loops", + in: StopInput{ + ContextCanceled: true, + MaxLoopsReached: true, + RunError: errSample, + }, + reason: StopReasonCanceled, + }, + { + name: "max_loops_wins_over_error", + in: StopInput{ + MaxLoopsReached: true, + RunError: errSample, + }, + reason: StopReasonMaxLoops, + }, + { + name: "error_when_no_max_loop_flag", + in: StopInput{ + RunError: errSample, + }, + reason: StopReasonError, + }, + { + name: "success", + in: StopInput{ + Success: true, + }, + reason: StopReasonSuccess, + }, + { + name: "context_canceled_on_error_field", + in: StopInput{ + RunError: context.Canceled, + }, + reason: StopReasonCanceled, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, _ := DecideStopReason(tc.in) + if got != tc.reason { + t.Fatalf("DecideStopReason() = %q, want %q", got, tc.reason) + } + }) + } +} diff --git a/internal/runtime/controlplane/envelope.go b/internal/runtime/controlplane/envelope.go new file mode 100644 index 00000000..ec2006fd --- /dev/null +++ b/internal/runtime/controlplane/envelope.go @@ -0,0 +1,4 @@ +package controlplane + +// PayloadVersion 为 runtime 事件 envelope 的当前协议版本号。 +const PayloadVersion = 1 diff --git a/internal/runtime/controlplane/phase.go b/internal/runtime/controlplane/phase.go new file mode 100644 index 00000000..e43b583d --- /dev/null +++ b/internal/runtime/controlplane/phase.go @@ -0,0 +1,13 @@ +package controlplane + +// Phase 表示单轮 ReAct 内的显式阶段(plan -> execute -> verify)。 +type Phase string + +const ( + // PhasePlan 规划阶段:构建上下文、调用 provider 直至得到 assistant 消息(含工具调用决策)。 + PhasePlan Phase = "plan" + // PhaseExecute 执行阶段:执行本批次全部工具调用。 + PhaseExecute Phase = "execute" + // PhaseVerify 验证阶段:工具结果已回灌,等待下一轮 provider 校验或收尾。 + PhaseVerify Phase = "verify" +) diff --git a/internal/runtime/controlplane/progress.go b/internal/runtime/controlplane/progress.go new file mode 100644 index 00000000..339f530b --- /dev/null +++ b/internal/runtime/controlplane/progress.go @@ -0,0 +1,53 @@ +package controlplane + +// ProgressEvidenceKind 标识工具/适配器产出的证据类型,runtime 仅聚合不做语义推断。 +type ProgressEvidenceKind string + +const ( + // EvidenceNewInfoNonDup 表示本轮引入了非重复的新信息(用于 streak 回归约束)。 + EvidenceNewInfoNonDup ProgressEvidenceKind = "EVIDENCE_NEW_INFO_NON_DUP" +) + +// ProgressEvidenceRecord 描述一条可计分的进展证据。 +type ProgressEvidenceRecord struct { + Kind ProgressEvidenceKind `json:"kind"` + Detail string `json:"detail,omitempty"` +} + +// ProgressScore 表示一次评估后的分值增量与 streak 快照。 +type ProgressScore struct { + ScoreDelta int `json:"score_delta"` + NoProgressStreak int `json:"no_progress_streak"` + RepeatCycleStreak int `json:"repeat_cycle_streak"` +} + +// ProgressState 汇总当前运行期 progress 控制面状态。 +type ProgressState struct { + LastScore ProgressScore `json:"last_score"` +} + +// ApplyProgressEvidence 根据证据更新分值与 streak。 +// 若仅出现 EVIDENCE_NEW_INFO_NON_DUP,则只增加 score_delta,不重置 no_progress_streak(回归约束)。 +func ApplyProgressEvidence(state ProgressState, records []ProgressEvidenceRecord) ProgressState { + next := state.LastScore + if len(records) == 0 { + next.NoProgressStreak++ + return ProgressState{LastScore: next} + } + + onlyNonDup := true + for _, r := range records { + if r.Kind != EvidenceNewInfoNonDup { + onlyNonDup = false + break + } + } + if onlyNonDup { + next.ScoreDelta++ + return ProgressState{LastScore: next} + } + + next.NoProgressStreak = 0 + next.ScoreDelta++ + return ProgressState{LastScore: next} +} diff --git a/internal/runtime/controlplane/progress_test.go b/internal/runtime/controlplane/progress_test.go new file mode 100644 index 00000000..7969bf2e --- /dev/null +++ b/internal/runtime/controlplane/progress_test.go @@ -0,0 +1,42 @@ +package controlplane + +import "testing" + +func TestApplyProgressEvidenceNoEvidenceIncrementsNoProgress(t *testing.T) { + t.Parallel() + state := ProgressState{} + next := ApplyProgressEvidence(state, nil) + if next.LastScore.NoProgressStreak != 1 { + t.Fatalf("expected no_progress_streak 1, got %d", next.LastScore.NoProgressStreak) + } +} + +func TestApplyProgressEvidenceOnlyNonDupDoesNotResetNoProgressStreak(t *testing.T) { + t.Parallel() + state := ProgressState{ + LastScore: ProgressScore{NoProgressStreak: 3}, + } + next := ApplyProgressEvidence(state, []ProgressEvidenceRecord{ + {Kind: EvidenceNewInfoNonDup}, + }) + if next.LastScore.NoProgressStreak != 3 { + t.Fatalf("expected streak unchanged at 3, got %d", next.LastScore.NoProgressStreak) + } + if next.LastScore.ScoreDelta != 1 { + t.Fatalf("expected score_delta 1, got %d", next.LastScore.ScoreDelta) + } +} + +func TestApplyProgressEvidenceMixedResetsNoProgress(t *testing.T) { + t.Parallel() + state := ProgressState{ + LastScore: ProgressScore{NoProgressStreak: 2}, + } + next := ApplyProgressEvidence(state, []ProgressEvidenceRecord{ + {Kind: EvidenceNewInfoNonDup}, + {Kind: ProgressEvidenceKind("other_evidence")}, + }) + if next.LastScore.NoProgressStreak != 0 { + t.Fatalf("expected streak reset, got %d", next.LastScore.NoProgressStreak) + } +} diff --git a/internal/runtime/controlplane/stop_reason.go b/internal/runtime/controlplane/stop_reason.go new file mode 100644 index 00000000..d08b6cf6 --- /dev/null +++ b/internal/runtime/controlplane/stop_reason.go @@ -0,0 +1,15 @@ +package controlplane + +// StopReason 表示一次 Run 的最终停止原因,互斥且由决议器唯一确定。 +type StopReason string + +const ( + // StopReasonSuccess 表示助手正常结束(无待执行工具调用)。 + StopReasonSuccess StopReason = "success" + // StopReasonMaxLoops 表示达到配置的最大推理轮数。 + StopReasonMaxLoops StopReason = "max_loops" + // StopReasonError 表示不可恢复的运行时或 provider 错误。 + StopReasonError StopReason = "error" + // StopReasonCanceled 表示运行上下文被取消(含用户中断)。 + StopReasonCanceled StopReason = "canceled" +) diff --git a/internal/runtime/errors.go b/internal/runtime/errors.go deleted file mode 100644 index c0ded3b7..00000000 --- a/internal/runtime/errors.go +++ /dev/null @@ -1,48 +0,0 @@ -package runtime - -import ( - "context" - "errors" - "log" - "math/rand/v2" - "time" - - "neo-code/internal/provider" -) - -// handleRunError 负责把运行错误映射为最终 runtime 事件。 -func (s *Service) handleRunError(ctx context.Context, runID string, sessionID string, err error) error { - if errors.Is(err, context.Canceled) { - s.emit(ctx, EventRunCanceled, runID, sessionID, nil) - return context.Canceled - } - - var providerErr *provider.ProviderError - if errors.As(err, &providerErr) { - log.Printf("runtime: provider error (status=%d, code=%s, retryable=%v): %s", - providerErr.StatusCode, providerErr.Code, providerErr.Retryable, providerErr.Message) - } - - s.emit(ctx, EventError, runID, sessionID, err.Error()) - return err -} - -// isRetryableProviderError 判断 provider 错误是否允许 runtime 级重试。 -func isRetryableProviderError(err error) bool { - var providerErr *provider.ProviderError - if !errors.As(err, &providerErr) { - return false - } - return providerErr.Retryable -} - -// providerRetryBackoff 计算 runtime 级 provider 重试等待时间。 -func providerRetryBackoff(attempt int) time.Duration { - wait := providerRetryBaseWait << (attempt - 1) - jitter := float64(wait) * (0.5 + rand.Float64()) - wait = time.Duration(jitter) - if wait > providerRetryMaxWait { - wait = providerRetryMaxWait - } - return wait -} diff --git a/internal/runtime/event_emitter.go b/internal/runtime/event_emitter.go new file mode 100644 index 00000000..43080dbb --- /dev/null +++ b/internal/runtime/event_emitter.go @@ -0,0 +1,71 @@ +package runtime + +import ( + "context" + "time" + + "neo-code/internal/runtime/controlplane" +) + +const turnUnspecified = -1 + +// emit 将 runtime 事件投递到事件通道,并在通道阻塞且上下文取消时返回错误。 +func (s *Service) emit(ctx context.Context, kind EventType, runID string, sessionID string, payload any) error { + return s.emitWithEnvelope(ctx, RuntimeEvent{ + Type: kind, + RunID: runID, + SessionID: sessionID, + Turn: turnUnspecified, + Timestamp: time.Now(), + PayloadVersion: controlplane.PayloadVersion, + Payload: payload, + }) +} + +// emitRunScoped 携带当前 run 的 turn/phase 元数据发出事件。 +func (s *Service) emitRunScoped(ctx context.Context, kind EventType, state *runState, payload any) error { + if state == nil { + return s.emit(ctx, kind, "", "", payload) + } + phase := "" + if state.phase != "" { + phase = string(state.phase) + } + return s.emitWithEnvelope(ctx, RuntimeEvent{ + Type: kind, + RunID: state.runID, + SessionID: state.session.ID, + Turn: state.turn, + Phase: phase, + Timestamp: time.Now(), + PayloadVersion: controlplane.PayloadVersion, + Payload: payload, + }) +} + +func (s *Service) emitWithEnvelope(ctx context.Context, evt RuntimeEvent) error { + if evt.PayloadVersion == 0 { + evt.PayloadVersion = controlplane.PayloadVersion + } + if evt.Timestamp.IsZero() { + evt.Timestamp = time.Now() + } + if err := s.deliverEvent(ctx, evt); err != nil { + return err + } + return nil +} + +func (s *Service) deliverEvent(ctx context.Context, evt RuntimeEvent) error { + select { + case s.events <- evt: + return nil + default: + } + select { + case s.events <- evt: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/internal/runtime/events.go b/internal/runtime/events.go index 97eadc6a..a79eca47 100644 --- a/internal/runtime/events.go +++ b/internal/runtime/events.go @@ -1,5 +1,11 @@ package runtime +import ( + "time" + + "neo-code/internal/runtime/controlplane" +) + // EventType identifies the kind of runtime event emitted during a run. type EventType string @@ -7,10 +13,41 @@ type EventType string // for a specific run. RunID is provided by the caller and is echoed back on all // events so upper layers can ignore stale events from older runs. type RuntimeEvent struct { - Type EventType - RunID string - SessionID string - Payload any + Type EventType + RunID string + SessionID string + Turn int + Phase string + Timestamp time.Time + PayloadVersion int + Payload any +} + +// PhaseChangedPayload 描述 phase 迁移。 +type PhaseChangedPayload struct { + From string `json:"from"` + To string `json:"to"` +} + +// BudgetCheckedPayload 为预算检查壳事件负载(1A 仅占位,1B阶段使用)。 +type BudgetCheckedPayload struct { + Note string `json:"note,omitempty"` +} + +// ProgressEvaluatedPayload 汇总 progress 控制面评估结果。 +type ProgressEvaluatedPayload struct { + Score controlplane.ProgressScore `json:"score"` +} + +// StopReasonDecidedPayload 承载唯一停止原因决议结果。 +type StopReasonDecidedPayload struct { + Reason controlplane.StopReason `json:"reason"` + Detail string `json:"detail,omitempty"` +} + +// LedgerReconciledPayload 为账本对账壳事件负载(1A 仅占位)。 +type LedgerReconciledPayload struct { + Note string `json:"note,omitempty"` } // PermissionRequestPayload 描述一次需要审批的权限请求上下文。 @@ -69,18 +106,24 @@ const ( // EventProviderRetry is emitted when runtime retries a provider call due to // a retryable error (e.g. 429, 5xx). Payload is a human-readable message. EventProviderRetry EventType = "provider_retry" - // EventPermissionRequest is emitted when a tool call hits an ask decision. - EventPermissionRequest EventType = "permission_request" + // EventPermissionRequested 是 1A 权限请求事件名。 + EventPermissionRequested EventType = "permission_requested" // EventPermissionResolved is emitted when runtime resolves a permission request or denial. EventPermissionResolved EventType = "permission_resolved" // EventCompactStart is emitted when a compact cycle starts. EventCompactStart EventType = "compact_start" - // EventCompactDone is emitted when a compact cycle completes. - EventCompactDone EventType = "compact_done" + // EventCompactApplied 表示一次 compact 已成功应用或校验完成(1A 主事件)。 + EventCompactApplied EventType = "compact_applied" // EventCompactError is emitted when compact fails. EventCompactError EventType = "compact_error" // EventTokenUsage is emitted after each provider response with token statistics. EventTokenUsage EventType = "token_usage" + // EventPhaseChanged 表示显式 phase 迁移。 + EventPhaseChanged EventType = "phase_changed" + // EventProgressEvaluated 表示 progress 评估结果。 + EventProgressEvaluated EventType = "progress_evaluated" + // EventStopReasonDecided 表示唯一停止原因已决议。 + EventStopReasonDecided EventType = "stop_reason_decided" ) // TokenUsagePayload carries token usage statistics for a single provider turn. diff --git a/internal/runtime/memo.go b/internal/runtime/memo.go index a285c63b..477933d7 100644 --- a/internal/runtime/memo.go +++ b/internal/runtime/memo.go @@ -26,26 +26,3 @@ func isSuccessfulRememberToolCall(callName string, result tools.ToolResult, exec } return strings.TrimSpace(callName) == tools.ToolNameMemoRemember } - -// cloneMessages 深拷贝消息切片,避免后台调度读取到后续运行态修改。 -func cloneMessages(messages []providertypes.Message) []providertypes.Message { - if len(messages) == 0 { - return nil - } - - cloned := make([]providertypes.Message, 0, len(messages)) - for _, message := range messages { - next := message - if len(message.ToolCalls) > 0 { - next.ToolCalls = append([]providertypes.ToolCall(nil), message.ToolCalls...) - } - if len(message.ToolMetadata) > 0 { - next.ToolMetadata = make(map[string]string, len(message.ToolMetadata)) - for key, value := range message.ToolMetadata { - next.ToolMetadata[key] = value - } - } - cloned = append(cloned, next) - } - return cloned -} diff --git a/internal/runtime/permission.go b/internal/runtime/permission.go index 2cc231c3..960da86a 100644 --- a/internal/runtime/permission.go +++ b/internal/runtime/permission.go @@ -170,7 +170,7 @@ func (s *Service) awaitPermissionDecision( } defer s.approvalBroker.Close(requestID) - s.emit(ctx, EventPermissionRequest, input.RunID, input.SessionID, PermissionRequestPayload{ + s.emit(ctx, EventPermissionRequested, input.RunID, input.SessionID, PermissionRequestPayload{ RequestID: requestID, ToolCallID: input.Call.ID, ToolName: input.Call.Name, diff --git a/internal/runtime/permission_test.go b/internal/runtime/permission_test.go index 447924a4..b2054bdd 100644 --- a/internal/runtime/permission_test.go +++ b/internal/runtime/permission_test.go @@ -209,7 +209,7 @@ waitRequest: case <-time.After(3 * time.Second): t.Fatalf("timed out waiting permission request") case event := <-service.Events(): - if event.Type != EventPermissionRequest { + if !isPermissionRequestEvent(event.Type) { continue } payload, ok := event.Payload.(PermissionRequestPayload) @@ -327,7 +327,7 @@ waitRequest: case <-time.After(3 * time.Second): t.Fatalf("timed out waiting permission request") case event := <-service.Events(): - if event.Type != EventPermissionRequest { + if !isPermissionRequestEvent(event.Type) { continue } payload, ok := event.Payload.(PermissionRequestPayload) @@ -462,7 +462,7 @@ waitRequest: case <-time.After(3 * time.Second): t.Fatalf("timed out waiting permission request") case event := <-service.Events(): - if event.Type != EventPermissionRequest { + if !isPermissionRequestEvent(event.Type) { continue } payload, ok := event.Payload.(PermissionRequestPayload) @@ -590,7 +590,7 @@ func TestServiceRunMCPPermissionHardDenyFlow(t *testing.T) { events := collectRuntimeEvents(service.Events()) assertEventSequence(t, events, []EventType{EventPermissionResolved, EventToolResult, EventAgentDone}) - assertNoEventType(t, events, EventPermissionRequest) + assertNoPermissionRequestFlow(t, events) foundResolved := false for _, event := range events { diff --git a/internal/runtime/run.go b/internal/runtime/run.go index b0caf20a..345a9f10 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -13,6 +13,7 @@ import ( "neo-code/internal/provider" "neo-code/internal/provider/streaming" providertypes "neo-code/internal/provider/types" + "neo-code/internal/runtime/controlplane" agentsession "neo-code/internal/session" "neo-code/internal/tools" ) @@ -20,19 +21,25 @@ import ( // Run 执行一次完整的 ReAct 闭环:保存用户输入、驱动模型、执行工具并发出事件。 // 已有会话会先加锁再加载/更新,确保同一会话并发 Run 不会出现状态覆盖; // 新会话在创建后再绑定会话锁,不同会话可并行执行。 -func (s *Service) Run(ctx context.Context, input UserInput) error { - if strings.TrimSpace(input.Content) == "" { - return errors.New("runtime: input content is empty") - } +func (s *Service) Run(ctx context.Context, input UserInput) (err error) { + var statePtr *runState runCtx, cancel := context.WithCancel(ctx) runToken := s.startRun(cancel) + defer func() { + s.emitRunTermination(runCtx, input, statePtr, err) + }() defer func() { cancel() s.finishRun(runToken) }() ctx = runCtx + if strings.TrimSpace(input.Content) == "" { + err = errors.New("runtime: input content is empty") + return err + } + initialCfg := s.configManager.Get() sessionID := strings.TrimSpace(input.SessionID) releaseSessionLock := func() {} @@ -64,6 +71,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { } state := newRunState(input.RunID, session) + statePtr = &state if err := s.appendUserMessageAndSave(ctx, &state, input.Content); err != nil { return s.handleRunError(ctx, state.runID, state.session.ID, err) } @@ -71,11 +79,13 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { for turn := 0; ; turn++ { maxLoops := resolveMaxLoops(s.configManager.Get()) if turn >= maxLoops { - err := errors.New("runtime: max loop reached") - s.emit(ctx, EventError, state.runID, state.session.ID, err.Error()) + err = ErrMaxLoopReached return err } + state.turn = turn + s.transitionRunPhase(ctx, &state, controlplane.PhasePlan) + for { if err := ctx.Err(); err != nil { return s.handleRunError(ctx, state.runID, state.session.ID, err) @@ -118,9 +128,44 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { s.triggerMemoExtraction(state.session.ID, state.session.Messages, state.rememberedThisRun) return nil } + s.transitionRunPhase(ctx, &state, controlplane.PhaseExecute) if err := s.executeAssistantToolCalls(ctx, &state, snapshot, turnResult.assistant); err != nil { return s.handleRunError(ctx, state.runID, state.session.ID, err) } + s.transitionRunPhase(ctx, &state, controlplane.PhaseVerify) + + var evidence []controlplane.ProgressEvidenceRecord + hasProgress := false + toolCallCount := len(turnResult.assistant.ToolCalls) + state.mu.Lock() + if len(state.session.Messages) >= toolCallCount { + for i := len(state.session.Messages) - toolCallCount; i < len(state.session.Messages); i++ { + msg := state.session.Messages[i] + if msg.Role == providertypes.RoleTool && !msg.IsError { + hasProgress = true + break + } + } + } + state.mu.Unlock() + + if hasProgress { + evidence = append(evidence, controlplane.ProgressEvidenceRecord{Kind: controlplane.EvidenceNewInfoNonDup}) + } + + state.mu.Lock() + state.progress = controlplane.ApplyProgressEvidence(state.progress, evidence) + streak := state.progress.LastScore.NoProgressStreak + currentScore := state.progress.LastScore + state.mu.Unlock() + + s.emitRunScoped(ctx, EventProgressEvaluated, &state, ProgressEvaluatedPayload{Score: currentScore}) + + if streak >= 3 { + err = ErrNoProgressStreakLimit + return err + } + break } } @@ -139,8 +184,8 @@ func (s *Service) prepareTurnSnapshot(ctx context.Context, state *runState) (tur Shell: cfg.Shell, Provider: cfg.SelectedProvider, Model: cfg.CurrentModel, - SessionInputTokens: state.tokenInputTotal, - SessionOutputTokens: state.tokenOutputTotal, + SessionInputTokens: state.session.TokenInputTotal, + SessionOutputTokens: state.session.TokenOutputTotal, }, Compact: agentcontext.CompactOptions{ DisableMicroCompact: cfg.Context.Compact.MicroCompactDisabled, @@ -201,7 +246,7 @@ func (s *Service) callProviderWithRetry( for retryAttempt := 0; retryAttempt <= defaultProviderRetryMax; retryAttempt++ { if retryAttempt > 0 { wait := providerRetryBackoff(retryAttempt) - s.emit(ctx, EventProviderRetry, state.runID, state.session.ID, + s.emitRunScoped(ctx, EventProviderRetry, state, fmt.Sprintf("retrying provider call (attempt %d/%d, wait=%.1fs)...", retryAttempt, defaultProviderRetryMax, wait.Seconds())) @@ -219,10 +264,10 @@ func (s *Service) callProviderWithRetry( streamOutcome := generateStreamingMessage(ctx, modelProvider, snapshot.request, streaming.Hooks{ OnTextDelta: func(text string) { - s.emit(ctx, EventAgentChunk, state.runID, state.session.ID, text) + s.emitRunScoped(ctx, EventAgentChunk, state, text) }, OnToolCallStart: func(payload providertypes.ToolCallStartPayload) { - s.emit(ctx, EventToolCallThinking, state.runID, state.session.ID, payload.Name) + s.emitRunScoped(ctx, EventToolCallThinking, state, payload.Name) }, }) if streamOutcome.err != nil { @@ -254,11 +299,11 @@ func (s *Service) emitTokenUsage(ctx context.Context, state *runState, result pr if result.inputTokens == 0 && result.outputTokens == 0 { return } - s.emit(ctx, EventTokenUsage, state.runID, state.session.ID, TokenUsagePayload{ + s.emitRunScoped(ctx, EventTokenUsage, state, TokenUsagePayload{ InputTokens: result.inputTokens, OutputTokens: result.outputTokens, - SessionInputTokens: state.tokenInputTotal, - SessionOutputTokens: state.tokenOutputTotal, + SessionInputTokens: state.session.TokenInputTotal, + SessionOutputTokens: state.session.TokenOutputTotal, }) } diff --git a/internal/runtime/run_lifecycle.go b/internal/runtime/run_lifecycle.go new file mode 100644 index 00000000..a0dcd8c9 --- /dev/null +++ b/internal/runtime/run_lifecycle.go @@ -0,0 +1,146 @@ +package runtime + +import ( + "context" + "errors" + "log" + "math/rand/v2" + "strings" + "time" + + "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" + "neo-code/internal/runtime/controlplane" +) + +// ErrMaxLoopReached 表示达到配置的最大推理轮数,用于停止原因分类与测试断言。 +var ErrMaxLoopReached = errors.New("runtime: max loop reached") + +// ErrNoProgressStreakLimit 表示循环内连续多次未取得进展,触发死循环拦截。 +var ErrNoProgressStreakLimit = errors.New("runtime: no progress streak limit reached") + +// transitionRunPhase 在阶段变化时发出 phase_changed 并更新 runState。 +func (s *Service) transitionRunPhase(ctx context.Context, state *runState, next controlplane.Phase) { + if state == nil || state.phase == next { + return + } + from := state.phase + state.phase = next + _ = s.emitRunScoped(ctx, EventPhaseChanged, state, PhaseChangedPayload{ + From: string(from), + To: string(next), + }) +} + +// emitRunTermination 在 Run 退出时决议并发出唯一 stop_reason_decided 终止事实事件。 +func (s *Service) emitRunTermination(ctx context.Context, input UserInput, state *runState, err error) { + runID := strings.TrimSpace(input.RunID) + sessionID := strings.TrimSpace(input.SessionID) + if state != nil { + if strings.TrimSpace(state.runID) != "" { + runID = state.runID + } + if strings.TrimSpace(state.session.ID) != "" { + sessionID = state.session.ID + } + if state.stopEmitted { + return + } + state.stopEmitted = true + } + + in := controlplane.StopInput{Success: err == nil} + if err != nil { + in.Success = false + switch { + case errors.Is(err, context.Canceled): + in.ContextCanceled = true + case errors.Is(err, ErrMaxLoopReached): + in.MaxLoopsReached = true + in.RunError = err + default: + in.RunError = err + } + } + + reason, detail := controlplane.DecideStopReason(in) + turn := turnUnspecified + phase := "" + if state != nil { + turn = state.turn + if state.phase != "" { + phase = string(state.phase) + } + } + _ = s.emitWithEnvelope(ctx, RuntimeEvent{ + Type: EventStopReasonDecided, + RunID: runID, + SessionID: sessionID, + Turn: turn, + Phase: phase, + Timestamp: time.Now(), + PayloadVersion: controlplane.PayloadVersion, + Payload: StopReasonDecidedPayload{Reason: reason, Detail: detail}, + }) +} + +// handleRunError 负责记录 provider 错误日志并原样返回错误;终止类事件由 Run 出口统一发出。 +func (s *Service) handleRunError(ctx context.Context, runID string, sessionID string, err error) error { + _ = ctx + _ = runID + _ = sessionID + if errors.Is(err, context.Canceled) { + return context.Canceled + } + + var providerErr *provider.ProviderError + if errors.As(err, &providerErr) { + log.Printf("runtime: provider error (status=%d, code=%s, retryable=%v): %s", + providerErr.StatusCode, providerErr.Code, providerErr.Retryable, providerErr.Message) + } + + return err +} + +// isRetryableProviderError 判断 provider 错误是否允许 runtime 级重试。 +func isRetryableProviderError(err error) bool { + var providerErr *provider.ProviderError + if !errors.As(err, &providerErr) { + return false + } + return providerErr.Retryable +} + +// providerRetryBackoff 计算 runtime 级 provider 重试等待时间。 +func providerRetryBackoff(attempt int) time.Duration { + wait := providerRetryBaseWait << (attempt - 1) + jitter := float64(wait) * (0.5 + rand.Float64()) + wait = time.Duration(jitter) + if wait > providerRetryMaxWait { + wait = providerRetryMaxWait + } + return wait +} + +// cloneMessages 深拷贝消息切片,避免后台调度读取到后续运行态修改。 +func cloneMessages(messages []providertypes.Message) []providertypes.Message { + if len(messages) == 0 { + return nil + } + + cloned := make([]providertypes.Message, 0, len(messages)) + for _, message := range messages { + next := message + if len(message.ToolCalls) > 0 { + next.ToolCalls = append([]providertypes.ToolCall(nil), message.ToolCalls...) + } + if len(message.ToolMetadata) > 0 { + next.ToolMetadata = make(map[string]string, len(message.ToolMetadata)) + for key, value := range message.ToolMetadata { + next.ToolMetadata[key] = value + } + } + cloned = append(cloned, next) + } + return cloned +} diff --git a/internal/runtime/run_termination_test.go b/internal/runtime/run_termination_test.go new file mode 100644 index 00000000..eda607a2 --- /dev/null +++ b/internal/runtime/run_termination_test.go @@ -0,0 +1,49 @@ +package runtime + +import ( + "context" + "errors" + "testing" + + "neo-code/internal/runtime/controlplane" + agentsession "neo-code/internal/session" +) + +func TestEmitRunTerminationEmitsStopReasonOnce(t *testing.T) { + t.Parallel() + + s := NewWithFactory(newRuntimeConfigManager(t), &stubToolManager{}, newMemoryStore(), &scriptedProviderFactory{ + provider: &scriptedProvider{}, + }, nil) + + state := newRunState("run-t", agentsessionFixture(t)) + errSample := errors.New("boom") + + s.emitRunTermination(context.Background(), UserInput{RunID: "run-t", SessionID: state.session.ID}, &state, errSample) + s.emitRunTermination(context.Background(), UserInput{RunID: "run-t", SessionID: state.session.ID}, &state, errSample) + + events := collectRuntimeEvents(s.Events()) + var stops int + for _, e := range events { + if e.Type == EventStopReasonDecided { + stops++ + p, ok := e.Payload.(StopReasonDecidedPayload) + if !ok { + t.Fatalf("expected StopReasonDecidedPayload, got %#v", e.Payload) + } + if p.Reason != controlplane.StopReasonError { + t.Fatalf("reason = %q, want error", p.Reason) + } + } + } + if stops != 1 { + t.Fatalf("expected exactly one stop_reason_decided, got %d", stops) + } +} + +func agentsessionFixture(t *testing.T) agentsession.Session { + t.Helper() + s := agentsession.New("t") + s.ID = "sess-t" + return s +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index beece19f..a7a8c44f 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -2,8 +2,6 @@ package runtime import ( "context" - "path/filepath" - "strings" "sync" "time" @@ -152,151 +150,3 @@ func (s *Service) LoadSession(ctx context.Context, id string) (agentsession.Sess } return session, nil } - -// loadOrCreateSession 负责在运行开始时解析工作目录并加载或创建会话。 -func (s *Service) loadOrCreateSession( - ctx context.Context, - sessionID string, - title string, - defaultWorkdir string, - requestedWorkdir string, -) (agentsession.Session, error) { - if strings.TrimSpace(sessionID) == "" { - sessionWorkdir, err := resolveWorkdirForSession(defaultWorkdir, "", requestedWorkdir) - if err != nil { - return agentsession.Session{}, err - } - session := agentsession.NewWithWorkdir(title, sessionWorkdir) - if err := s.sessionStore.Save(ctx, &session); err != nil { - return agentsession.Session{}, err - } - return session, nil - } - - session, err := s.sessionStore.Load(ctx, sessionID) - if err != nil { - return agentsession.Session{}, err - } - if strings.TrimSpace(requestedWorkdir) == "" && strings.TrimSpace(session.Workdir) != "" { - return session, nil - } - - resolved, err := resolveWorkdirForSession(defaultWorkdir, session.Workdir, requestedWorkdir) - if err != nil { - return agentsession.Session{}, err - } - if session.Workdir == resolved { - return session, nil - } - - session.Workdir = resolved - session.UpdatedAt = time.Now() - if err := s.sessionStore.Save(ctx, &session); err != nil { - return agentsession.Session{}, err - } - return session, nil -} - -// emit 将 runtime 事件投递到事件通道,并在通道阻塞且上下文取消时返回错误。 -func (s *Service) emit(ctx context.Context, kind EventType, runID string, sessionID string, payload any) error { - evt := RuntimeEvent{ - Type: kind, - RunID: runID, - SessionID: sessionID, - Payload: payload, - } - select { - case s.events <- evt: - return nil - default: - } - select { - case s.events <- evt: - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -// startRun 记录当前激活的运行取消句柄,并分配一个新的运行令牌。 -func (s *Service) startRun(cancel context.CancelFunc) uint64 { - s.runMu.Lock() - defer s.runMu.Unlock() - if s.activeRunCancels == nil { - s.activeRunCancels = make(map[uint64]context.CancelFunc) - } - - s.nextRunToken++ - token := s.nextRunToken - s.activeRunToken = token - s.activeRunCancels[token] = cancel - return token -} - -// finishRun 在运行结束时释放指定运行的取消句柄,并回退到最新活跃运行。 -func (s *Service) finishRun(token uint64) { - s.runMu.Lock() - defer s.runMu.Unlock() - - delete(s.activeRunCancels, token) - if s.activeRunToken != token { - return - } - - s.activeRunToken = 0 - for activeToken := range s.activeRunCancels { - if activeToken > s.activeRunToken { - s.activeRunToken = activeToken - } - } -} - -// acquireSessionLock 获取指定会话锁并返回释放引用的函数。 -// 调用方在完成会话级串行操作后,必须调用 release 以允许锁条目回收。 -func (s *Service) acquireSessionLock(sessionID string) (*sync.Mutex, func()) { - s.sessionMu.Lock() - if s.sessionLocks == nil { - s.sessionLocks = make(map[string]*sessionLockEntry) - } - - entry, ok := s.sessionLocks[sessionID] - if !ok { - entry = &sessionLockEntry{} - s.sessionLocks[sessionID] = entry - } - entry.refs++ - s.sessionMu.Unlock() - - released := false - release := func() { - s.sessionMu.Lock() - defer s.sessionMu.Unlock() - if released { - return - } - released = true - - current, exists := s.sessionLocks[sessionID] - if !exists || current != entry { - return - } - current.refs-- - if current.refs <= 0 { - delete(s.sessionLocks, sessionID) - } - } - return &entry.mu, release -} - -func resolveWorkdirForSession(defaultWorkdir string, currentWorkdir string, requestedWorkdir string) (string, error) { - base := agentsession.EffectiveWorkdir(currentWorkdir, defaultWorkdir) - if strings.TrimSpace(requestedWorkdir) == "" { - return agentsession.ResolveExistingDir(base) - } - - target := strings.TrimSpace(requestedWorkdir) - if !filepath.IsAbs(target) { - target = filepath.Join(base, target) - } - return agentsession.ResolveExistingDir(target) -} diff --git a/internal/runtime/runtime_internal_helpers_test.go b/internal/runtime/runtime_internal_helpers_test.go index 3b5472f6..bb772baf 100644 --- a/internal/runtime/runtime_internal_helpers_test.go +++ b/internal/runtime/runtime_internal_helpers_test.go @@ -40,7 +40,6 @@ func TestRunStateNilReceiverNoops(t *testing.T) { t.Parallel() var state *runState - state.syncSessionTokenTotals() state.recordUsage(3, 5) state.resetTokenTotals() state.touchSession() @@ -53,18 +52,13 @@ func TestRunStateMutationsAndSync(t *testing.T) { state := newRunState("run-1", session) state.recordUsage(10, 20) - if state.tokenInputTotal != 11 || state.tokenOutputTotal != 22 { - t.Fatalf("unexpected token totals: in=%d out=%d", state.tokenInputTotal, state.tokenOutputTotal) - } - - state.syncSessionTokenTotals() if state.session.TokenInputTotal != 11 || state.session.TokenOutputTotal != 22 { - t.Fatalf("session totals not synced: %+v", state.session) + t.Fatalf("unexpected token totals: in=%d out=%d", state.session.TokenInputTotal, state.session.TokenOutputTotal) } state.resetTokenTotals() - if state.tokenInputTotal != 0 || state.tokenOutputTotal != 0 { - t.Fatalf("expected reset totals to be zero, got in=%d out=%d", state.tokenInputTotal, state.tokenOutputTotal) + if state.session.TokenInputTotal != 0 || state.session.TokenOutputTotal != 0 { + t.Fatalf("expected reset totals to be zero, got in=%d out=%d", state.session.TokenInputTotal, state.session.TokenOutputTotal) } before := state.session.UpdatedAt @@ -74,7 +68,7 @@ func TestRunStateMutationsAndSync(t *testing.T) { t.Fatalf("expected touchSession to update time") } if state.session.TokenInputTotal != 1 || state.session.TokenOutputTotal != 2 { - t.Fatalf("expected touchSession to sync totals") + t.Fatalf("expected recordUsage to sync totals") } } diff --git a/internal/runtime/runtime_progress_test.go b/internal/runtime/runtime_progress_test.go new file mode 100644 index 00000000..d02deb80 --- /dev/null +++ b/internal/runtime/runtime_progress_test.go @@ -0,0 +1,86 @@ +package runtime + +import ( + "context" + "errors" + "testing" + + "neo-code/internal/config" + providertypes "neo-code/internal/provider/types" + "neo-code/internal/runtime/controlplane" + "neo-code/internal/tools" +) + +func TestProgressStreakStopsRun(t *testing.T) { + t.Setenv("TEST_KEY", "dummy") + + cfg := config.Config{ + Providers: []config.ProviderConfig{{Name: "test-progress", Driver: "test", BaseURL: "http://localhost", Model: "test", APIKeyEnv: "TEST_KEY"}}, + SelectedProvider: "test-progress", + MaxLoops: 10, // High enough to not trigger max loops + Workdir: t.TempDir(), + } + + toolManager := &stubToolManager{ + specs: []providertypes.ToolSpec{ + {Name: "tool_error"}, + }, + executeFn: func(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + // Always return error to avoid generating progress + return tools.ToolResult{Content: "error occurred", IsError: true}, nil + }, + } + + providerFactory := &scriptedProviderFactory{ + provider: &scriptedProvider{ + chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { + // the model always decides to call the tool + events <- providertypes.NewToolCallStartStreamEvent(0, "call_err", "tool_error") + events <- providertypes.NewToolCallDeltaStreamEvent(0, "call_err", "{}") + events <- providertypes.NewMessageDoneStreamEvent("tool_calls", nil) + return nil + }, + }, + } + + manager := config.NewManager(config.NewLoader(t.TempDir(), &cfg)) + + service := NewWithFactory( + manager, + toolManager, + newMemoryStore(), + providerFactory, + nil, + ) + + input := UserInput{ + RunID: "run-progress", + Content: "trigger error loop", + } + + err := service.Run(context.Background(), input) + if err == nil { + t.Fatal("expected error from streak limit, got nil") + } + + if !errors.Is(err, ErrNoProgressStreakLimit) { + t.Fatalf("expected ErrNoProgressStreakLimit, got %v", err) + } + + events := collectRuntimeEvents(service.Events()) + + // Verify StopReason is error and specifies the streak limit + assertEventContains(t, events, EventStopReasonDecided) + + for _, e := range events { + if e.Type == EventStopReasonDecided { + payload := e.Payload.(StopReasonDecidedPayload) + if payload.Reason != controlplane.StopReasonError { + t.Errorf("expected StopReasonError, got %s", payload.Reason) + } + if payload.Detail != ErrNoProgressStreakLimit.Error() { + t.Errorf("expected detail to be %q, got %q", ErrNoProgressStreakLimit.Error(), payload.Detail) + } + } + } +} diff --git a/internal/runtime/runtime_remaining_branches_test.go b/internal/runtime/runtime_remaining_branches_test.go index 01dadba4..96c53dc3 100644 --- a/internal/runtime/runtime_remaining_branches_test.go +++ b/internal/runtime/runtime_remaining_branches_test.go @@ -250,7 +250,7 @@ func TestExecuteToolCallWithPermissionRemainingBranches(t *testing.T) { service := &Service{toolManager: manager, approvalBroker: approvalflow.NewBroker(), events: make(chan RuntimeEvent, 16)} go func() { for evt := range service.events { - if evt.Type != EventPermissionRequest { + if !isPermissionRequestEvent(evt.Type) { continue } payload := evt.Payload.(PermissionRequestPayload) @@ -326,7 +326,7 @@ func TestExecuteToolCallWithPermissionRemainingBranches(t *testing.T) { func resolveDecisionFromEvent(service *Service, decision approvalflow.Decision) { for evt := range service.events { - if evt.Type != EventPermissionRequest { + if !isPermissionRequestEvent(evt.Type) { continue } payload := evt.Payload.(PermissionRequestPayload) diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 20cde84e..58e6bc56 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -339,6 +339,7 @@ type stubToolManager struct { listCalls int executeCalls int lastInput tools.ToolCallInput + executeFn func(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) rememberErr error remembered []struct { sessionID string @@ -368,6 +369,9 @@ func (m *stubToolManager) MicroCompactPolicy(name string) tools.MicroCompactPoli func (m *stubToolManager) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { m.executeCalls++ m.lastInput = input + if m.executeFn != nil { + return m.executeFn(ctx, input) + } result := m.result if result.Name == "" { result.Name = input.Name @@ -859,7 +863,7 @@ func TestServiceRunRejectsProviderCompletionWithoutMessageDone(t *testing.T) { } events := collectRuntimeEvents(service.Events()) - assertEventSequence(t, events, []EventType{EventUserMessage, EventAgentChunk, EventError}) + assertEventSequence(t, events, []EventType{EventUserMessage, EventAgentChunk, EventStopReasonDecided}) assertNoEventType(t, events, EventAgentDone) session := onlySession(t, store) @@ -1358,7 +1362,7 @@ waitRequest: case <-deadline: t.Fatalf("timed out waiting permission request event") case event := <-service.Events(): - if event.Type != EventPermissionRequest { + if !isPermissionRequestEvent(event.Type) { continue } payload, ok := event.Payload.(PermissionRequestPayload) @@ -1474,7 +1478,7 @@ func TestServiceRunEmitsPermissionResolvedForDeny(t *testing.T) { EventToolResult, EventAgentDone, }) - assertNoEventType(t, events, EventPermissionRequest) + assertNoPermissionRequestFlow(t, events) assertNoEventType(t, events, EventError) for _, event := range events { @@ -1569,7 +1573,7 @@ func TestServiceRunEmitsRememberScopeWhenSessionRejectMemoryHits(t *testing.T) { events := collectRuntimeEvents(service.Events()) assertEventSequence(t, events, []EventType{EventPermissionResolved, EventToolResult, EventAgentDone}) - assertNoEventType(t, events, EventPermissionRequest) + assertNoPermissionRequestFlow(t, events) for _, event := range events { if event.Type != EventPermissionResolved { @@ -1604,7 +1608,7 @@ func TestServiceRunHandlesToolManagerSpecError(t *testing.T) { } events := collectRuntimeEvents(service.Events()) - assertEventSequence(t, events, []EventType{EventUserMessage, EventError}) + assertEventSequence(t, events, []EventType{EventUserMessage, EventStopReasonDecided}) assertNoEventType(t, events, EventAgentDone) assertEventsRunID(t, events, input.RunID) @@ -1671,7 +1675,7 @@ func TestServiceRunErrorPaths(t *testing.T) { }, registerTool: &stubTool{name: "filesystem_edit", content: "loop tool output"}, expectErr: "max loop reached", - expectEvents: []EventType{EventUserMessage, EventToolStart, EventToolChunk, EventToolResult, EventError}, + expectEvents: []EventType{EventUserMessage, EventToolStart, EventToolChunk, EventToolResult, EventStopReasonDecided}, assert: func(t *testing.T, store *memoryStore, scripted *scriptedProvider, tool *stubTool) { t.Helper() if scripted.callCount != 1 { @@ -1690,7 +1694,7 @@ func TestServiceRunErrorPaths(t *testing.T) { expectErr: "factory failed", expectEvents: []EventType{ EventUserMessage, - EventError, + EventStopReasonDecided, }, }, { @@ -1779,7 +1783,7 @@ func TestServiceRunErrorPaths(t *testing.T) { }, }, expectErr: "invalid api key", - expectEvents: []EventType{EventUserMessage, EventError}, + expectEvents: []EventType{EventUserMessage, EventStopReasonDecided}, assert: func(t *testing.T, store *memoryStore, scripted *scriptedProvider, tool *stubTool) { t.Helper() if scripted.callCount != 1 { @@ -1802,7 +1806,7 @@ func TestServiceRunErrorPaths(t *testing.T) { }, }, expectErr: "internal server error", - expectEvents: []EventType{EventUserMessage, EventProviderRetry, EventError}, + expectEvents: []EventType{EventUserMessage, EventProviderRetry, EventStopReasonDecided}, assert: func(t *testing.T, store *memoryStore, scripted *scriptedProvider, tool *stubTool) { t.Helper() // 1 initial + 2 retries = 3 calls @@ -1905,7 +1909,7 @@ func TestServiceCancelActiveRun(t *testing.T) { } events := collectRuntimeEvents(service.Events()) - assertEventSequence(t, events, []EventType{EventUserMessage, EventRunCanceled}) + assertEventSequence(t, events, []EventType{EventUserMessage, EventStopReasonDecided}) assertNoEventType(t, events, EventError) assertEventsRunID(t, events, input.RunID) } @@ -2018,7 +2022,7 @@ func TestServiceRunCanceledByProvider(t *testing.T) { } events := collectRuntimeEvents(service.Events()) - assertEventSequence(t, events, []EventType{EventUserMessage, EventRunCanceled}) + assertEventSequence(t, events, []EventType{EventUserMessage, EventStopReasonDecided}) assertNoEventType(t, events, EventError) assertEventsRunID(t, events, input.RunID) @@ -2063,8 +2067,7 @@ func TestServiceRunPreservesProviderErrorAfterCancel(t *testing.T) { } events := collectRuntimeEvents(service.Events()) - assertEventSequence(t, events, []EventType{EventUserMessage, EventError}) - assertNoEventType(t, events, EventRunCanceled) + assertEventSequence(t, events, []EventType{EventUserMessage, EventStopReasonDecided}) assertEventsRunID(t, events, input.RunID) } @@ -2118,9 +2121,8 @@ func TestServiceRunCanceledDuringToolExecution(t *testing.T) { } events := collectRuntimeEvents(service.Events()) - assertEventSequence(t, events, []EventType{EventUserMessage, EventToolStart, EventToolChunk, EventRunCanceled}) + assertEventSequence(t, events, []EventType{EventUserMessage, EventToolStart, EventToolChunk, EventStopReasonDecided}) assertNoEventType(t, events, EventToolResult) - assertNoEventType(t, events, EventError) assertEventsRunID(t, events, input.RunID) session := onlySession(t, store) @@ -2180,8 +2182,7 @@ func TestServiceRunPreservesToolErrorAfterCancel(t *testing.T) { } events := collectRuntimeEvents(service.Events()) - assertEventSequence(t, events, []EventType{EventUserMessage, EventToolStart, EventToolChunk, EventToolResult, EventRunCanceled}) - assertNoEventType(t, events, EventError) + assertEventSequence(t, events, []EventType{EventUserMessage, EventToolStart, EventToolChunk, EventToolResult, EventStopReasonDecided}) assertEventsRunID(t, events, input.RunID) session := onlySession(t, store) @@ -2217,8 +2218,7 @@ func TestServiceRunPreservesSessionSaveErrorAfterCancel(t *testing.T) { } events := collectRuntimeEvents(service.Events()) - assertEventSequence(t, events, []EventType{EventError}) - assertNoEventType(t, events, EventRunCanceled) + assertEventSequence(t, events, []EventType{EventStopReasonDecided}) assertEventsRunID(t, events, input.RunID) } @@ -2263,7 +2263,6 @@ func TestServiceRunToolTimeoutIsNotCancellation(t *testing.T) { events := collectRuntimeEvents(service.Events()) assertEventSequence(t, events, []EventType{EventUserMessage, EventToolStart, EventToolResult, EventAgentDone}) - assertNoEventType(t, events, EventRunCanceled) assertEventsRunID(t, events, input.RunID) session := onlySession(t, store) @@ -2329,7 +2328,7 @@ func TestServiceCompactManualAppliesAndPersists(t *testing.T) { } events := collectRuntimeEvents(service.Events()) - assertEventSequence(t, events, []EventType{EventCompactStart, EventCompactDone}) + assertEventSequence(t, events, []EventType{EventCompactStart, EventCompactApplied}) assertEventsRunID(t, events, "run-manual") } @@ -2369,7 +2368,7 @@ func TestServiceCompactManualFailureReturnsError(t *testing.T) { events := collectRuntimeEvents(service.Events()) assertEventSequence(t, events, []EventType{EventCompactStart, EventCompactError}) - assertNoEventType(t, events, EventCompactDone) + assertNoEventType(t, events, EventCompactApplied) } func TestServiceCompactUsesSessionProviderAndModelWhenPresent(t *testing.T) { @@ -2571,7 +2570,7 @@ func TestServiceManualCompactThenRunContinuesToolRound(t *testing.T) { events := collectRuntimeEvents(service.Events()) assertEventSequence(t, events, []EventType{ EventCompactStart, - EventCompactDone, + EventCompactApplied, EventUserMessage, EventToolStart, EventToolResult, @@ -2930,6 +2929,11 @@ func collectRuntimeEvents(events <-chan RuntimeEvent) []RuntimeEvent { } } +// isPermissionRequestEvent 判断是否为权限请求类事件(含 1A 主事件与兼容旧名)。 +func isPermissionRequestEvent(typ EventType) bool { + return typ == EventPermissionRequested +} + func assertEventSequence(t *testing.T, events []RuntimeEvent, expected []EventType) { t.Helper() for _, eventType := range expected { @@ -2955,6 +2959,16 @@ func assertNoEventType(t *testing.T, events []RuntimeEvent, unexpected EventType } } +// assertNoPermissionRequestFlow 断言未出现需要用户审批的权限请求事件(新旧名均排除)。 +func assertNoPermissionRequestFlow(t *testing.T, events []RuntimeEvent) { + t.Helper() + for _, event := range events { + if isPermissionRequestEvent(event.Type) { + t.Fatalf("did not expect permission request event %q in %+v", event.Type, events) + } + } +} + func assertEventsRunID(t *testing.T, events []RuntimeEvent, runID string) { t.Helper() for _, event := range events { @@ -3565,7 +3579,7 @@ func TestServiceRunAutoCompactsAndResetsSessionTokens(t *testing.T) { assertEventSequence(t, events, []EventType{ EventUserMessage, EventCompactStart, - EventCompactDone, + EventCompactApplied, EventToolStart, EventToolResult, EventAgentDone, @@ -3574,7 +3588,7 @@ func TestServiceRunAutoCompactsAndResetsSessionTokens(t *testing.T) { foundAutoDone := false for _, event := range events { - if event.Type != EventCompactDone { + if event.Type != EventCompactApplied { continue } payload, ok := event.Payload.(CompactResult) @@ -3827,14 +3841,14 @@ func TestServiceRunReactivelyCompactsOnContextTooLong(t *testing.T) { assertEventSequence(t, events, []EventType{ EventUserMessage, EventCompactStart, - EventCompactDone, + EventCompactApplied, EventAgentDone, }) assertNoEventType(t, events, EventCompactError) foundReactiveDone := false for _, event := range events { - if event.Type != EventCompactDone { + if event.Type != EventCompactApplied { continue } payload, ok := event.Payload.(CompactResult) @@ -3931,7 +3945,7 @@ func TestServiceRunReactivelyCompactsWithinSingleLoopBudget(t *testing.T) { assertEventSequence(t, events, []EventType{ EventUserMessage, EventCompactStart, - EventCompactDone, + EventCompactApplied, EventAgentDone, }) assertNoEventType(t, events, EventError) @@ -3982,9 +3996,9 @@ func TestServiceRunReactiveCompactDegradesUpToMaxAttempts(t *testing.T) { EventUserMessage, EventCompactStart, EventCompactError, - EventError, + EventStopReasonDecided, }) - assertNoEventType(t, events, EventCompactDone) + assertNoEventType(t, events, EventCompactApplied) foundReactiveError := false for _, event := range events { @@ -4042,10 +4056,10 @@ func TestServiceRunDoesNotReactiveCompactOnPlainTextTokenThrottle(t *testing.T) events := collectRuntimeEvents(service.Events()) assertEventSequence(t, events, []EventType{ EventUserMessage, - EventError, + EventStopReasonDecided, }) assertNoEventType(t, events, EventCompactStart) - assertNoEventType(t, events, EventCompactDone) + assertNoEventType(t, events, EventCompactApplied) assertNoEventType(t, events, EventCompactError) } @@ -4059,11 +4073,11 @@ func TestRestoreSessionTokens(t *testing.T) { state := newRunState("", session) - if state.tokenInputTotal != 500 { - t.Fatalf("expected sessionInputTokens == 500, got %d", state.tokenInputTotal) + if state.session.TokenInputTotal != 500 { + t.Fatalf("expected sessionInputTokens == 500, got %d", state.session.TokenInputTotal) } - if state.tokenOutputTotal != 200 { - t.Fatalf("expected sessionOutputTokens == 200, got %d", state.tokenOutputTotal) + if state.session.TokenOutputTotal != 200 { + t.Fatalf("expected sessionOutputTokens == 200, got %d", state.session.TokenOutputTotal) } } @@ -4077,11 +4091,11 @@ func TestRestoreSessionTokensNewSession(t *testing.T) { state := newRunState("", session) - if state.tokenInputTotal != 0 { - t.Fatalf("expected sessionInputTokens == 0, got %d", state.tokenInputTotal) + if state.session.TokenInputTotal != 0 { + t.Fatalf("expected sessionInputTokens == 0, got %d", state.session.TokenInputTotal) } - if state.tokenOutputTotal != 0 { - t.Fatalf("expected sessionOutputTokens == 0, got %d", state.tokenOutputTotal) + if state.session.TokenOutputTotal != 0 { + t.Fatalf("expected sessionOutputTokens == 0, got %d", state.session.TokenOutputTotal) } } @@ -4165,8 +4179,8 @@ func TestTokenUsageRecordedOnMessageDone(t *testing.T) { service.emit(context.Background(), EventTokenUsage, "test-run-id", "test-session-id", TokenUsagePayload{ InputTokens: payload.Usage.InputTokens, OutputTokens: payload.Usage.OutputTokens, - SessionInputTokens: state.tokenInputTotal, - SessionOutputTokens: state.tokenOutputTotal, + SessionInputTokens: state.session.TokenInputTotal, + SessionOutputTokens: state.session.TokenOutputTotal, }) } }}, @@ -4176,11 +4190,11 @@ func TestTokenUsageRecordedOnMessageDone(t *testing.T) { } // Verify the service counters are updated - if state.tokenInputTotal != 100 { - t.Fatalf("expected sessionInputTokens == 100, got %d", state.tokenInputTotal) + if state.session.TokenInputTotal != 100 { + t.Fatalf("expected sessionInputTokens == 100, got %d", state.session.TokenInputTotal) } - if state.tokenOutputTotal != 50 { - t.Fatalf("expected sessionOutputTokens == 50, got %d", state.tokenOutputTotal) + if state.session.TokenOutputTotal != 50 { + t.Fatalf("expected sessionOutputTokens == 50, got %d", state.session.TokenOutputTotal) } // Verify EventTokenUsage was emitted with correct payload @@ -4209,3 +4223,119 @@ func TestTokenUsageRecordedOnMessageDone(t *testing.T) { t.Fatalf("expected SessionOutputTokens == 50, got %d", tokenUsagePayload.SessionOutputTokens) } } + +func assertEventContains(t *testing.T, events []RuntimeEvent, expected EventType) { + t.Helper() + for _, e := range events { + if e.Type == expected { + return + } + } + t.Errorf("expected event %q to be in sequence, but not found", expected) +} + +func TestParallelToolCallsPhaseMigration(t *testing.T) { + t.Parallel() + + callsCount := 0 + mu := sync.Mutex{} + + toolManager := &stubToolManager{ + specs: []providertypes.ToolSpec{ + {Name: "tool_a"}, + {Name: "tool_b"}, + {Name: "tool_c"}, + }, + executeFn: func(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + mu.Lock() + callsCount++ + mu.Unlock() + time.Sleep(10 * time.Millisecond) + if input.Name == "tool_a" { + return tools.ToolResult{Content: "result_a"}, nil + } else if input.Name == "tool_b" { + return tools.ToolResult{Content: "result_b"}, nil + } + return tools.ToolResult{Content: "result_c"}, nil + }, + } + + providerFactory := &scriptedProviderFactory{ + provider: &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call_1", Name: "tool_a", Arguments: "{}"}, + {ID: "call_2", Name: "tool_b", Arguments: "{}"}, + {ID: "call_3", Name: "tool_c", Arguments: "{}"}, + }, + }, + FinishReason: "tool_calls", + }, + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Content: "done", + }, + FinishReason: "stop", + }, + }, + }, + } + + service := NewWithFactory( + newRuntimeConfigManager(t), + toolManager, + newMemoryStore(), + providerFactory, + nil, + ) + + input := UserInput{ + RunID: "run-parallel", + Content: "run parallel tools", + } + + if err := service.Run(context.Background(), input); err != nil { + t.Fatalf("Run() failed: %v", err) + } + + mu.Lock() + if callsCount != 3 { + t.Errorf("expected 3 tool calls executed, got %d", callsCount) + } + mu.Unlock() + + events := collectRuntimeEvents(service.Events()) + + // We expect EventPhaseChanged to emit plan -> execute -> verify + var phaseChanges []PhaseChangedPayload + for _, e := range events { + if e.Type == EventPhaseChanged { + payload := e.Payload.(PhaseChangedPayload) + phaseChanges = append(phaseChanges, payload) + } + } + + expectedTransitions := []PhaseChangedPayload{ + {From: "", To: "plan"}, + {From: "plan", To: "execute"}, + {From: "execute", To: "verify"}, + {From: "verify", To: "plan"}, + } + + if len(phaseChanges) < len(expectedTransitions) { + t.Errorf("expected at least %d phase transitions, got %d", len(expectedTransitions), len(phaseChanges)) + } else { + for i, exp := range expectedTransitions { + if phaseChanges[i] != exp { + t.Errorf("transition %d: expected %+v, got %+v", i, exp, phaseChanges[i]) + } + } + } + + assertEventContains(t, events, EventToolStart) + assertEventContains(t, events, EventToolResult) +} diff --git a/internal/runtime/session_mutation.go b/internal/runtime/session_mutation.go index 9e3ab972..cdd3dd15 100644 --- a/internal/runtime/session_mutation.go +++ b/internal/runtime/session_mutation.go @@ -19,7 +19,7 @@ func (s *Service) appendUserMessageAndSave(ctx context.Context, state *runState, if err := s.sessionStore.Save(ctx, &state.session); err != nil { return err } - s.emit(ctx, EventUserMessage, state.runID, state.session.ID, message) + s.emitRunScoped(ctx, EventUserMessage, state, message) return nil } @@ -33,7 +33,6 @@ func (s *Service) appendAssistantMessageAndSave( metadataChanged := state.session.Provider != snapshot.providerConfig.Name || state.session.Model != snapshot.model state.session.Provider = snapshot.providerConfig.Name state.session.Model = snapshot.model - state.syncSessionTokenTotals() if strings.TrimSpace(assistant.Content) != "" || len(assistant.ToolCalls) > 0 { state.session.Messages = append(state.session.Messages, assistant) @@ -54,6 +53,8 @@ func (s *Service) appendToolMessageAndSave( call providertypes.ToolCall, result tools.ToolResult, ) error { + state.mu.Lock() + defer state.mu.Unlock() toolMessage := providertypes.Message{ Role: providertypes.RoleTool, Content: result.Content, diff --git a/internal/runtime/session_scheduler.go b/internal/runtime/session_scheduler.go new file mode 100644 index 00000000..ed783622 --- /dev/null +++ b/internal/runtime/session_scheduler.go @@ -0,0 +1,138 @@ +package runtime + +import ( + "context" + "path/filepath" + "strings" + "sync" + "time" + + agentsession "neo-code/internal/session" +) + +// loadOrCreateSession 负责在运行开始时解析工作目录并加载或创建会话。 +func (s *Service) loadOrCreateSession( + ctx context.Context, + sessionID string, + title string, + defaultWorkdir string, + requestedWorkdir string, +) (agentsession.Session, error) { + if strings.TrimSpace(sessionID) == "" { + sessionWorkdir, err := resolveWorkdirForSession(defaultWorkdir, "", requestedWorkdir) + if err != nil { + return agentsession.Session{}, err + } + session := agentsession.NewWithWorkdir(title, sessionWorkdir) + if err := s.sessionStore.Save(ctx, &session); err != nil { + return agentsession.Session{}, err + } + return session, nil + } + + session, err := s.sessionStore.Load(ctx, sessionID) + if err != nil { + return agentsession.Session{}, err + } + if strings.TrimSpace(requestedWorkdir) == "" && strings.TrimSpace(session.Workdir) != "" { + return session, nil + } + + resolved, err := resolveWorkdirForSession(defaultWorkdir, session.Workdir, requestedWorkdir) + if err != nil { + return agentsession.Session{}, err + } + if session.Workdir == resolved { + return session, nil + } + + session.Workdir = resolved + session.UpdatedAt = time.Now() + if err := s.sessionStore.Save(ctx, &session); err != nil { + return agentsession.Session{}, err + } + return session, nil +} + +// startRun 记录当前激活的运行取消句柄,并分配一个新的运行令牌。 +func (s *Service) startRun(cancel context.CancelFunc) uint64 { + s.runMu.Lock() + defer s.runMu.Unlock() + if s.activeRunCancels == nil { + s.activeRunCancels = make(map[uint64]context.CancelFunc) + } + + s.nextRunToken++ + token := s.nextRunToken + s.activeRunToken = token + s.activeRunCancels[token] = cancel + return token +} + +// finishRun 在运行结束时释放指定运行的取消句柄,并回退到最新活跃运行。 +func (s *Service) finishRun(token uint64) { + s.runMu.Lock() + defer s.runMu.Unlock() + + delete(s.activeRunCancels, token) + if s.activeRunToken != token { + return + } + + s.activeRunToken = 0 + for activeToken := range s.activeRunCancels { + if activeToken > s.activeRunToken { + s.activeRunToken = activeToken + } + } +} + +// acquireSessionLock 获取指定会话锁并返回释放引用的函数。 +// 调用方在完成会话级串行操作后,必须调用 release 以允许锁条目回收。 +func (s *Service) acquireSessionLock(sessionID string) (*sync.Mutex, func()) { + s.sessionMu.Lock() + if s.sessionLocks == nil { + s.sessionLocks = make(map[string]*sessionLockEntry) + } + + entry, ok := s.sessionLocks[sessionID] + if !ok { + entry = &sessionLockEntry{} + s.sessionLocks[sessionID] = entry + } + entry.refs++ + s.sessionMu.Unlock() + + released := false + release := func() { + s.sessionMu.Lock() + defer s.sessionMu.Unlock() + if released { + return + } + released = true + + current, exists := s.sessionLocks[sessionID] + if !exists || current != entry { + return + } + current.refs-- + if current.refs <= 0 { + delete(s.sessionLocks, sessionID) + } + } + return &entry.mu, release +} + +func resolveWorkdirForSession(defaultWorkdir string, currentWorkdir string, requestedWorkdir string) (string, error) { + base := agentsession.EffectiveWorkdir(currentWorkdir, defaultWorkdir) + if strings.TrimSpace(requestedWorkdir) == "" { + return agentsession.ResolveExistingDir(base) + } + + target := strings.TrimSpace(requestedWorkdir) + if !filepath.IsAbs(target) { + target = filepath.Join(base, target) + } + return agentsession.ResolveExistingDir(target) +} diff --git a/internal/runtime/state.go b/internal/runtime/state.go index 3b7d1c41..c0b160bc 100644 --- a/internal/runtime/state.go +++ b/internal/runtime/state.go @@ -1,11 +1,13 @@ package runtime import ( + "sync" "time" "neo-code/internal/config" "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" + "neo-code/internal/runtime/controlplane" agentsession "neo-code/internal/session" ) @@ -14,41 +16,33 @@ const maxReactiveCompactAttempts = 3 // runState 汇总单次 Run 生命周期内会变化的会话与计量状态。 type runState struct { + mu sync.Mutex runID string session agentsession.Session - tokenInputTotal int - tokenOutputTotal int compactApplied bool reactiveCompactAttempts int rememberedThisRun bool + turn int + phase controlplane.Phase + stopEmitted bool + progress controlplane.ProgressState } // newRunState 基于持久化会话创建一次运行的内存状态镜像。 func newRunState(runID string, session agentsession.Session) runState { return runState{ - runID: runID, - session: session, - tokenInputTotal: session.TokenInputTotal, - tokenOutputTotal: session.TokenOutputTotal, + runID: runID, + session: session, } } -// syncSessionTokenTotals 将运行期 token 计数同步回会话对象。 -func (s *runState) syncSessionTokenTotals() { - if s == nil { - return - } - s.session.TokenInputTotal = s.tokenInputTotal - s.session.TokenOutputTotal = s.tokenOutputTotal -} - // recordUsage 累加本轮 provider 返回的 token 使用量。 func (s *runState) recordUsage(inputTokens int, outputTokens int) { if s == nil { return } - s.tokenInputTotal += inputTokens - s.tokenOutputTotal += outputTokens + s.session.TokenInputTotal += inputTokens + s.session.TokenOutputTotal += outputTokens } // resetTokenTotals 在 compact 应用成功后清零当前运行的 token 账本。 @@ -56,17 +50,15 @@ func (s *runState) resetTokenTotals() { if s == nil { return } - s.tokenInputTotal = 0 - s.tokenOutputTotal = 0 - s.syncSessionTokenTotals() + s.session.TokenInputTotal = 0 + s.session.TokenOutputTotal = 0 } -// touchSession 更新会话修改时间并同步最新 token 累计值。 +// touchSession 更新会话修改时间。 func (s *runState) touchSession() { if s == nil { return } - s.syncSessionTokenTotals() s.session.UpdatedAt = time.Now() } diff --git a/internal/runtime/toolexec.go b/internal/runtime/toolexec.go index 24cd074e..f651adcd 100644 --- a/internal/runtime/toolexec.go +++ b/internal/runtime/toolexec.go @@ -4,61 +4,104 @@ import ( "context" "errors" "strings" + "sync" providertypes "neo-code/internal/provider/types" ) -// executeAssistantToolCalls 顺序执行 assistant 返回的全部工具调用并回写结果。 +// executeAssistantToolCalls 并发执行 assistant 返回的全部工具调用并回写结果。 func (s *Service) executeAssistantToolCalls( ctx context.Context, state *runState, snapshot turnSnapshot, assistant providertypes.Message, ) error { + var wg sync.WaitGroup + var mu sync.Mutex + var firstErr error + for _, call := range assistant.ToolCalls { if err := ctx.Err(); err != nil { return err } - s.emit(ctx, EventToolStart, state.runID, state.session.ID, call) + wg.Add(1) + go func(call providertypes.ToolCall) { + defer wg.Done() + s.emitRunScoped(ctx, EventToolStart, state, call) - result, execErr := s.executeToolCallWithPermission(ctx, permissionExecutionInput{ - RunID: state.runID, - SessionID: state.session.ID, - Call: call, - Workdir: snapshot.workdir, - ToolTimeout: snapshot.toolTimeout, - }) - if errors.Is(execErr, context.Canceled) { - return execErr - } - if execErr == nil { - if err := ctx.Err(); err != nil { - return err + result, execErr := s.executeToolCallWithPermission(ctx, permissionExecutionInput{ + RunID: state.runID, + SessionID: state.session.ID, + Call: call, + Workdir: snapshot.workdir, + ToolTimeout: snapshot.toolTimeout, + }) + + if errors.Is(execErr, context.Canceled) { + mu.Lock() + if firstErr == nil { + firstErr = execErr + } + mu.Unlock() + return + } + if execErr == nil { + if err := ctx.Err(); err != nil { + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + return + } } - } - if execErr != nil && strings.TrimSpace(result.Content) == "" { - result.Content = execErr.Error() - } - if err := s.appendToolMessageAndSave(ctx, state, call, result); err != nil { - if execErr != nil && errors.Is(err, context.Canceled) { - s.emit(ctx, EventToolResult, state.runID, state.session.ID, result) + if execErr != nil && strings.TrimSpace(result.Content) == "" { + result.Content = execErr.Error() } - return err - } - if err := ctx.Err(); err != nil && execErr == nil { - return err - } - s.emit(ctx, EventToolResult, state.runID, state.session.ID, result) - if isSuccessfulRememberToolCall(call.Name, result, execErr) { - state.rememberedThisRun = true - } - if execErr != nil { - if err := ctx.Err(); err != nil { - return err + if err := s.appendToolMessageAndSave(ctx, state, call, result); err != nil { + if execErr != nil && errors.Is(err, context.Canceled) { + s.emitRunScoped(ctx, EventToolResult, state, result) + } + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + return } - } + + if err := ctx.Err(); err != nil && execErr == nil { + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + return + } + + s.emitRunScoped(ctx, EventToolResult, state, result) + + if isSuccessfulRememberToolCall(call.Name, result, execErr) { + state.mu.Lock() + state.rememberedThisRun = true + state.mu.Unlock() + } + + if execErr != nil { + if err := ctx.Err(); err != nil { + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + return + } + } + }(call) } - return nil + + wg.Wait() + return firstErr } diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index 1017c474..a20336e6 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -799,13 +799,65 @@ var runtimeEventHandlerRegistry = map[agentruntime.EventType]func(*App, agentrun agentruntime.EventAgentChunk: runtimeEventAgentChunkHandler, agentruntime.EventToolChunk: runtimeEventToolChunkHandler, agentruntime.EventAgentDone: runtimeEventAgentDoneHandler, - agentruntime.EventRunCanceled: runtimeEventRunCanceledHandler, - agentruntime.EventError: runtimeEventErrorHandler, agentruntime.EventProviderRetry: runtimeEventProviderRetryHandler, - agentruntime.EventPermissionRequest: runtimeEventPermissionRequestHandler, + agentruntime.EventPermissionRequested: runtimeEventPermissionRequestHandler, agentruntime.EventPermissionResolved: runtimeEventPermissionResolvedHandler, - agentruntime.EventCompactDone: runtimeEventCompactDoneHandler, + agentruntime.EventCompactApplied: runtimeEventCompactDoneHandler, agentruntime.EventCompactError: runtimeEventCompactErrorHandler, + agentruntime.EventPhaseChanged: runtimeEventPhaseChangedHandler, + agentruntime.EventStopReasonDecided: runtimeEventStopReasonDecidedHandler, +} + +// runtimeEventPhaseChangedHandler 处理 phase 迁移并更新进度标签。 +func runtimeEventPhaseChangedHandler(a *App, event agentruntime.RuntimeEvent) bool { + payload, ok := event.Payload.(agentruntime.PhaseChangedPayload) + if !ok { + return false + } + switch strings.ToLower(strings.TrimSpace(payload.To)) { + case "plan": + a.setRunProgress(0.3, "Planning") + case "execute": + a.setRunProgress(0.6, "Running tools") + case "verify": + a.setRunProgress(0.82, "Verifying") + } + return false +} + +// runtimeEventStopReasonDecidedHandler 处理唯一终止事实事件。 +func runtimeEventStopReasonDecidedHandler(a *App, event agentruntime.RuntimeEvent) bool { + payload, ok := event.Payload.(agentruntime.StopReasonDecidedPayload) + if !ok { + return false + } + a.state.IsAgentRunning = false + a.state.StreamingReply = false + a.state.CurrentTool = "" + a.state.ActiveRunID = "" + a.pendingPermission = nil + a.clearRunProgress() + + reason := strings.ToLower(strings.TrimSpace(string(payload.Reason))) + switch reason { + case "success": + if strings.TrimSpace(a.state.ExecutionError) == "" { + a.state.StatusText = statusReady + } + case "canceled": + a.state.ExecutionError = "" + a.state.StatusText = statusCanceled + a.appendActivity("run", "Canceled current run", "", false) + default: + detail := strings.TrimSpace(payload.Detail) + if detail == "" { + detail = "runtime stopped" + } + a.state.ExecutionError = detail + a.state.StatusText = detail + a.appendActivity("run", "Runtime stopped", detail, true) + } + return false } // handleRuntimeEvent 通过注册表分发 runtime 事件,避免巨型 switch 膨胀。 @@ -1013,7 +1065,7 @@ func runtimeEventProviderRetryHandler(a *App, event agentruntime.RuntimeEvent) b return false } -// runtimeEventPermissionRequestHandler 处理 permission_request 事件并激活审批面板。 +// runtimeEventPermissionRequestHandler 处理 permission_requested 事件并激活审批面板。 func runtimeEventPermissionRequestHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := parsePermissionRequestPayload(event.Payload) if !ok { @@ -1082,7 +1134,7 @@ func (a *App) refreshPermissionPromptLayout() { a.applyComponentLayout(false) } -// runtimeEventCompactDoneHandler 处理 compact 完成事件。 +// runtimeEventCompactDoneHandler 处理 compact_applied 事件。 func runtimeEventCompactDoneHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := event.Payload.(agentruntime.CompactResult) if !ok { diff --git a/internal/tui/core/app/update_permission_test.go b/internal/tui/core/app/update_permission_test.go index fa0ad3af..9ea6528f 100644 --- a/internal/tui/core/app/update_permission_test.go +++ b/internal/tui/core/app/update_permission_test.go @@ -193,7 +193,7 @@ func TestSubmitPermissionDecisionValidation(t *testing.T) { func TestRuntimePermissionEventHandlers(t *testing.T) { app := newPermissionTestApp(&permissionTestRuntime{}) requestEvent := agentruntime.RuntimeEvent{ - Type: agentruntime.EventPermissionRequest, + Type: agentruntime.EventPermissionRequested, Payload: agentruntime.PermissionRequestPayload{ RequestID: "perm-4", ToolName: "bash", @@ -292,7 +292,7 @@ func TestRuntimePermissionRequestHandlerAutoRejectsSupersededRequest(t *testing. } event := agentruntime.RuntimeEvent{ - Type: agentruntime.EventPermissionRequest, + Type: agentruntime.EventPermissionRequested, Payload: agentruntime.PermissionRequestPayload{ RequestID: "perm-new", ToolName: "bash", @@ -330,7 +330,7 @@ func TestRuntimePermissionRequestHandlerDoesNotAutoRejectSubmittingRequest(t *te } runtimeEventPermissionRequestHandler(app, agentruntime.RuntimeEvent{ - Type: agentruntime.EventPermissionRequest, + Type: agentruntime.EventPermissionRequested, Payload: agentruntime.PermissionRequestPayload{ RequestID: "perm-new", }, @@ -348,7 +348,7 @@ func TestHandleRuntimeEventQueuesDeferredCommand(t *testing.T) { } model, cmd := app.Update(RuntimeMsg{Event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventPermissionRequest, + Type: agentruntime.EventPermissionRequested, Payload: agentruntime.PermissionRequestPayload{ RequestID: "perm-new", }, From 07a4d3b0d1a0ec54ae88514e8ac49d2348d6dc85 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Tue, 14 Apr 2026 10:17:23 +0000 Subject: [PATCH 2/9] fix(runtime): harden terminal event and tool parallelism Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/runtime/permission.go | 3 + internal/runtime/permission_test.go | 104 +++++++++++++ internal/runtime/run.go | 8 +- internal/runtime/run_lifecycle.go | 12 +- internal/runtime/run_termination_test.go | 38 +++++ internal/runtime/runtime.go | 4 + internal/runtime/runtime_test.go | 180 +++++++++++++++++++++++ internal/runtime/toolexec.go | 126 +++++++++++----- internal/tools/manager.go | 1 + 9 files changed, 435 insertions(+), 41 deletions(-) diff --git a/internal/runtime/permission.go b/internal/runtime/permission.go index 960da86a..e9408545 100644 --- a/internal/runtime/permission.go +++ b/internal/runtime/permission.go @@ -164,6 +164,9 @@ func (s *Service) awaitPermissionDecision( input permissionExecutionInput, permissionErr *tools.PermissionDecisionError, ) (approvalflow.Decision, string, error) { + s.permissionAskMu.Lock() + defer s.permissionAskMu.Unlock() + requestID, resultCh, err := s.approvalBroker.Open() if err != nil { return "", "", err diff --git a/internal/runtime/permission_test.go b/internal/runtime/permission_test.go index b2054bdd..000eefe4 100644 --- a/internal/runtime/permission_test.go +++ b/internal/runtime/permission_test.go @@ -152,6 +152,110 @@ func TestResolvePermissionDuplicateSubmissionIsNonBlocking(t *testing.T) { } } +func TestAwaitPermissionDecisionSerializesConcurrentAskRequests(t *testing.T) { + t.Parallel() + + service := NewWithFactory( + newRuntimeConfigManager(t), + &stubToolManager{}, + newMemoryStore(), + &scriptedProviderFactory{provider: &scriptedProvider{}}, + nil, + ) + permissionErr := permissionDecisionAskError(t) + + type awaitResult struct { + decision approvalflow.Decision + requestID string + err error + } + resultCh := make(chan awaitResult, 2) + + runAwait := func(callID string) { + decision, requestID, err := service.awaitPermissionDecision( + context.Background(), + permissionExecutionInput{ + RunID: "run-ask-serial", + SessionID: "session-ask-serial", + Call: providertypes.ToolCall{ + ID: callID, + Name: "filesystem_read_file", + }, + }, + permissionErr, + ) + resultCh <- awaitResult{decision: decision, requestID: requestID, err: err} + } + + go runAwait("call-1") + go runAwait("call-2") + + var firstReqID string + select { + case event := <-service.Events(): + if event.Type != EventPermissionRequested { + t.Fatalf("expected first event permission requested, got %q", event.Type) + } + payload, ok := event.Payload.(PermissionRequestPayload) + if !ok { + t.Fatalf("expected PermissionRequestPayload, got %#v", event.Payload) + } + firstReqID = payload.RequestID + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting first permission request") + } + + select { + case event := <-service.Events(): + t.Fatalf("unexpected second permission request before resolving first: %+v", event) + case <-time.After(80 * time.Millisecond): + } + + if err := service.ResolvePermission(context.Background(), PermissionResolutionInput{ + RequestID: firstReqID, + Decision: approvalflow.DecisionAllowOnce, + }); err != nil { + t.Fatalf("ResolvePermission(first) error = %v", err) + } + + var secondReqID string + select { + case event := <-service.Events(): + if event.Type != EventPermissionRequested { + t.Fatalf("expected second event permission requested, got %q", event.Type) + } + payload, ok := event.Payload.(PermissionRequestPayload) + if !ok { + t.Fatalf("expected PermissionRequestPayload, got %#v", event.Payload) + } + secondReqID = payload.RequestID + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting second permission request") + } + + if firstReqID == secondReqID { + t.Fatalf("expected distinct permission request IDs, got %q", firstReqID) + } + + if err := service.ResolvePermission(context.Background(), PermissionResolutionInput{ + RequestID: secondReqID, + Decision: approvalflow.DecisionAllowSession, + }); err != nil { + t.Fatalf("ResolvePermission(second) error = %v", err) + } + + for i := 0; i < 2; i++ { + select { + case res := <-resultCh: + if res.err != nil { + t.Fatalf("awaitPermissionDecision() error = %v", res.err) + } + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting awaitPermissionDecision result") + } + } +} + func TestServiceRunPermissionRejectFlow(t *testing.T) { t.Parallel() diff --git a/internal/runtime/run.go b/internal/runtime/run.go index 345a9f10..69052e6c 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -26,13 +26,13 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { runCtx, cancel := context.WithCancel(ctx) runToken := s.startRun(cancel) - defer func() { - s.emitRunTermination(runCtx, input, statePtr, err) - }() defer func() { cancel() s.finishRun(runToken) }() + defer func() { + s.emitRunTermination(runCtx, input, statePtr, err) + }() ctx = runCtx if strings.TrimSpace(input.Content) == "" { @@ -124,7 +124,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { s.emitTokenUsage(ctx, &state, turnResult) if len(turnResult.assistant.ToolCalls) == 0 { - s.emit(ctx, EventAgentDone, state.runID, state.session.ID, turnResult.assistant) + s.emitRunScoped(ctx, EventAgentDone, &state, turnResult.assistant) s.triggerMemoExtraction(state.session.ID, state.session.Messages, state.rememberedThisRun) return nil } diff --git a/internal/runtime/run_lifecycle.go b/internal/runtime/run_lifecycle.go index a0dcd8c9..52a5015f 100644 --- a/internal/runtime/run_lifecycle.go +++ b/internal/runtime/run_lifecycle.go @@ -72,7 +72,9 @@ func (s *Service) emitRunTermination(ctx context.Context, input UserInput, state phase = string(state.phase) } } - _ = s.emitWithEnvelope(ctx, RuntimeEvent{ + emitCtx, cancel := stopReasonEmitContext(ctx) + defer cancel() + _ = s.emitWithEnvelope(emitCtx, RuntimeEvent{ Type: EventStopReasonDecided, RunID: runID, SessionID: sessionID, @@ -84,6 +86,14 @@ func (s *Service) emitRunTermination(ctx context.Context, input UserInput, state }) } +// stopReasonEmitContext 为终止事件提供可用发送窗口,避免继承已取消上下文导致事实事件丢失。 +func stopReasonEmitContext(ctx context.Context) (context.Context, context.CancelFunc) { + if ctx != nil && ctx.Err() == nil { + return context.WithTimeout(ctx, terminationEventEmitTimeout) + } + return context.WithTimeout(context.Background(), terminationEventEmitTimeout) +} + // handleRunError 负责记录 provider 错误日志并原样返回错误;终止类事件由 Run 出口统一发出。 func (s *Service) handleRunError(ctx context.Context, runID string, sessionID string, err error) error { _ = ctx diff --git a/internal/runtime/run_termination_test.go b/internal/runtime/run_termination_test.go index eda607a2..1247cd9c 100644 --- a/internal/runtime/run_termination_test.go +++ b/internal/runtime/run_termination_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" "neo-code/internal/runtime/controlplane" agentsession "neo-code/internal/session" @@ -41,6 +42,43 @@ func TestEmitRunTerminationEmitsStopReasonOnce(t *testing.T) { } } +func TestEmitRunTerminationUsesFallbackContextWhenCanceled(t *testing.T) { + t.Parallel() + + s := NewWithFactory(newRuntimeConfigManager(t), &stubToolManager{}, newMemoryStore(), &scriptedProviderFactory{ + provider: &scriptedProvider{}, + }, nil) + s.events = make(chan RuntimeEvent, 1) + s.events <- RuntimeEvent{Type: EventAgentChunk} + + state := newRunState("run-cancel-fallback", agentsessionFixture(t)) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + done := make(chan struct{}) + go func() { + s.emitRunTermination(ctx, UserInput{RunID: "run-cancel-fallback", SessionID: state.session.ID}, &state, errors.New("boom")) + close(done) + }() + + select { + case <-done: + t.Fatalf("expected emitRunTermination to wait for channel availability") + case <-time.After(30 * time.Millisecond): + } + + <-s.events + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("emitRunTermination did not finish after freeing channel") + } + + events := collectRuntimeEvents(s.Events()) + assertEventContains(t, events, EventStopReasonDecided) +} + func agentsessionFixture(t *testing.T) agentsession.Session { t.Helper() s := agentsession.New("t") diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index a7a8c44f..96671d52 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -20,6 +20,9 @@ const ( providerRetryBaseWait = 1 * time.Second providerRetryMaxWait = 5 * time.Second defaultMaxLoops = 8 + defaultToolParallelism = 4 + + terminationEventEmitTimeout = 500 * time.Millisecond ) // Runtime 定义 runtime 对外暴露的运行、压缩与审批接口。 @@ -71,6 +74,7 @@ type Service struct { activeRunToken uint64 nextRunToken uint64 activeRunCancels map[uint64]context.CancelFunc + permissionAskMu sync.Mutex } // sessionLockEntry 维护单个会话锁及其当前引用计数,用于在无引用时回收 map 项。 diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 58e6bc56..397041aa 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "testing" "time" @@ -18,6 +19,7 @@ import ( "neo-code/internal/provider/streaming" providertypes "neo-code/internal/provider/types" approvalflow "neo-code/internal/runtime/approval" + "neo-code/internal/runtime/controlplane" "neo-code/internal/security" agentsession "neo-code/internal/session" "neo-code/internal/tools" @@ -4339,3 +4341,181 @@ func TestParallelToolCallsPhaseMigration(t *testing.T) { assertEventContains(t, events, EventToolStart) assertEventContains(t, events, EventToolResult) } + +func TestParallelToolCallsRespectConcurrencyLimit(t *testing.T) { + t.Parallel() + + var inFlight int32 + var maxInFlight int32 + toolSpecs := make([]providertypes.ToolSpec, 0, 12) + toolCalls := make([]providertypes.ToolCall, 0, 12) + for i := 0; i < 12; i++ { + name := fmt.Sprintf("tool_%d", i) + toolSpecs = append(toolSpecs, providertypes.ToolSpec{Name: name}) + toolCalls = append(toolCalls, providertypes.ToolCall{ID: fmt.Sprintf("call_%d", i), Name: name, Arguments: `{}`}) + } + + toolManager := &stubToolManager{ + specs: toolSpecs, + executeFn: func(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + current := atomic.AddInt32(&inFlight, 1) + defer atomic.AddInt32(&inFlight, -1) + for { + max := atomic.LoadInt32(&maxInFlight) + if current <= max { + break + } + if atomic.CompareAndSwapInt32(&maxInFlight, max, current) { + break + } + } + time.Sleep(20 * time.Millisecond) + return tools.ToolResult{Name: input.Name, Content: "ok"}, nil + }, + } + + providerFactory := &scriptedProviderFactory{ + provider: &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + ToolCalls: toolCalls, + }, + FinishReason: "tool_calls", + }, + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Content: "done", + }, + FinishReason: "stop", + }, + }, + }, + } + + service := NewWithFactory( + newRuntimeConfigManager(t), + toolManager, + newMemoryStore(), + providerFactory, + nil, + ) + + if err := service.Run(context.Background(), UserInput{RunID: "run-parallel-limit", Content: "parallel"}); err != nil { + t.Fatalf("Run() failed: %v", err) + } + + if got := atomic.LoadInt32(&maxInFlight); got > int32(defaultToolParallelism) { + t.Fatalf("max in-flight tool calls = %d, want <= %d", got, defaultToolParallelism) + } +} + +func TestParallelToolCallsSerializeSameToolName(t *testing.T) { + t.Parallel() + + var sharedInFlight int32 + var sharedOverlap int32 + + toolManager := &stubToolManager{ + specs: []providertypes.ToolSpec{{Name: "shared_tool"}}, + executeFn: func(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + current := atomic.AddInt32(&sharedInFlight, 1) + if current > 1 { + atomic.StoreInt32(&sharedOverlap, 1) + } + time.Sleep(25 * time.Millisecond) + atomic.AddInt32(&sharedInFlight, -1) + return tools.ToolResult{Name: input.Name, Content: "ok"}, nil + }, + } + + providerFactory := &scriptedProviderFactory{ + provider: &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call_1", Name: "shared_tool", Arguments: "{}"}, + {ID: "call_2", Name: "shared_tool", Arguments: "{}"}, + }, + }, + FinishReason: "tool_calls", + }, + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Content: "done", + }, + FinishReason: "stop", + }, + }, + }, + } + + service := NewWithFactory( + newRuntimeConfigManager(t), + toolManager, + newMemoryStore(), + providerFactory, + nil, + ) + + if err := service.Run(context.Background(), UserInput{RunID: "run-parallel-lock", Content: "parallel"}); err != nil { + t.Fatalf("Run() failed: %v", err) + } + + if atomic.LoadInt32(&sharedOverlap) != 0 { + t.Fatalf("same tool calls overlapped, expected serialized execution") + } +} + +func TestAgentDoneEventCarriesRunScopedEnvelope(t *testing.T) { + t.Parallel() + + service := NewWithFactory( + newRuntimeConfigManager(t), + &stubToolManager{}, + newMemoryStore(), + &scriptedProviderFactory{ + provider: &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + Content: "done", + }, + FinishReason: "stop", + }, + }, + }, + }, + nil, + ) + + if err := service.Run(context.Background(), UserInput{RunID: "run-agent-done-envelope", Content: "hello"}); err != nil { + t.Fatalf("Run() failed: %v", err) + } + + events := collectRuntimeEvents(service.Events()) + var doneEvent RuntimeEvent + found := false + for _, event := range events { + if event.Type == EventAgentDone { + doneEvent = event + found = true + break + } + } + if !found { + t.Fatalf("expected agent_done event") + } + if doneEvent.Turn == turnUnspecified { + t.Fatalf("expected run-scoped turn, got %d", doneEvent.Turn) + } + if doneEvent.Phase != string(controlplane.PhasePlan) { + t.Fatalf("expected phase=%q, got %q", controlplane.PhasePlan, doneEvent.Phase) + } +} diff --git a/internal/runtime/toolexec.go b/internal/runtime/toolexec.go index f651adcd..8067c286 100644 --- a/internal/runtime/toolexec.go +++ b/internal/runtime/toolexec.go @@ -16,17 +16,43 @@ func (s *Service) executeAssistantToolCalls( snapshot turnSnapshot, assistant providertypes.Message, ) error { + if len(assistant.ToolCalls) == 0 { + return nil + } + var wg sync.WaitGroup var mu sync.Mutex var firstErr error + semaphore := make(chan struct{}, resolveToolParallelism(len(assistant.ToolCalls))) + toolLocks := buildToolExecutionLocks(assistant.ToolCalls) + + checkContext := func() bool { + if err := ctx.Err(); err != nil { + rememberFirstError(&mu, &firstErr, err) + return true + } + return false + } for _, call := range assistant.ToolCalls { if err := ctx.Err(); err != nil { return err } wg.Add(1) - go func(call providertypes.ToolCall) { + go func(call providertypes.ToolCall, toolLock *sync.Mutex) { defer wg.Done() + if checkContext() { + return + } + if err := acquireToolSlot(ctx, semaphore); err != nil { + rememberFirstError(&mu, &firstErr, err) + return + } + defer releaseToolSlot(semaphore) + + toolLock.Lock() + defer toolLock.Unlock() + s.emitRunScoped(ctx, EventToolStart, state, call) result, execErr := s.executeToolCallWithPermission(ctx, permissionExecutionInput{ @@ -38,22 +64,11 @@ func (s *Service) executeAssistantToolCalls( }) if errors.Is(execErr, context.Canceled) { - mu.Lock() - if firstErr == nil { - firstErr = execErr - } - mu.Unlock() + rememberFirstError(&mu, &firstErr, execErr) return } - if execErr == nil { - if err := ctx.Err(); err != nil { - mu.Lock() - if firstErr == nil { - firstErr = err - } - mu.Unlock() - return - } + if execErr == nil && checkContext() { + return } if execErr != nil && strings.TrimSpace(result.Content) == "" { @@ -64,20 +79,11 @@ func (s *Service) executeAssistantToolCalls( if execErr != nil && errors.Is(err, context.Canceled) { s.emitRunScoped(ctx, EventToolResult, state, result) } - mu.Lock() - if firstErr == nil { - firstErr = err - } - mu.Unlock() + rememberFirstError(&mu, &firstErr, err) return } - if err := ctx.Err(); err != nil && execErr == nil { - mu.Lock() - if firstErr == nil { - firstErr = err - } - mu.Unlock() + if execErr == nil && checkContext() { return } @@ -89,19 +95,67 @@ func (s *Service) executeAssistantToolCalls( state.mu.Unlock() } - if execErr != nil { - if err := ctx.Err(); err != nil { - mu.Lock() - if firstErr == nil { - firstErr = err - } - mu.Unlock() - return - } + if execErr != nil && checkContext() { + return } - }(call) + }(call, toolLocks[normalizeToolLockKey(call.Name)]) } wg.Wait() return firstErr } + +// resolveToolParallelism 计算本轮工具执行的并发上限,避免无界 goroutine 扩散。 +func resolveToolParallelism(toolCallCount int) int { + if toolCallCount <= 0 { + return 1 + } + if toolCallCount < defaultToolParallelism { + return toolCallCount + } + return defaultToolParallelism +} + +// buildToolExecutionLocks 按工具名构造互斥锁,确保同名工具调用在单轮内串行执行。 +func buildToolExecutionLocks(calls []providertypes.ToolCall) map[string]*sync.Mutex { + locks := make(map[string]*sync.Mutex, len(calls)) + for _, call := range calls { + key := normalizeToolLockKey(call.Name) + if _, exists := locks[key]; !exists { + locks[key] = &sync.Mutex{} + } + } + return locks +} + +// normalizeToolLockKey 将工具名规范化为锁键,防止大小写差异导致重复并发执行。 +func normalizeToolLockKey(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +// acquireToolSlot 获取一个并发槽位,并在上下文取消时尽快返回。 +func acquireToolSlot(ctx context.Context, semaphore chan struct{}) error { + select { + case semaphore <- struct{}{}: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// releaseToolSlot 释放工具执行并发槽位。 +func releaseToolSlot(semaphore chan struct{}) { + <-semaphore +} + +// rememberFirstError 记录首次错误,后续错误只保留用于日志和事件路径。 +func rememberFirstError(mu *sync.Mutex, firstErr *error, err error) { + if err == nil { + return + } + mu.Lock() + defer mu.Unlock() + if *firstErr == nil { + *firstErr = err + } +} diff --git a/internal/tools/manager.go b/internal/tools/manager.go index df0840bf..51ae622f 100644 --- a/internal/tools/manager.go +++ b/internal/tools/manager.go @@ -21,6 +21,7 @@ type SpecListInput struct { type Manager interface { ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]providertypes.ToolSpec, error) MicroCompactPolicy(name string) MicroCompactPolicy + // Execute 必须支持并发调用;runtime 可能在同一轮中并行调度多个工具调用。 Execute(ctx context.Context, input ToolCallInput) (ToolResult, error) RememberSessionDecision(sessionID string, action security.Action, scope SessionPermissionScope) error } From a3168848060391fc50524f887d26a5ce0cbfed7f Mon Sep 17 00:00:00 2001 From: xgopilot Date: Tue, 14 Apr 2026 10:55:32 +0000 Subject: [PATCH 3/9] fix(runtime): align progress streak semantics and reduce permission HOL blocking - reset no-progress streak when evidence is present - scope permission ask serialization per run instead of global lock - remove test data race in stubToolManager under parallel tool execution - sync runtime event docs with emitted phase/progress events Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- docs/runtime-provider-event-flow.md | 2 + internal/runtime/controlplane/progress.go | 18 +---- .../runtime/controlplane/progress_test.go | 6 +- internal/runtime/permission.go | 56 ++++++++++++- internal/runtime/permission_test.go | 80 +++++++++++++++++++ internal/runtime/runtime.go | 42 ++++++---- internal/runtime/runtime_progress_test.go | 57 +++++++++++++ internal/runtime/runtime_test.go | 19 ++++- 8 files changed, 238 insertions(+), 42 deletions(-) diff --git a/docs/runtime-provider-event-flow.md b/docs/runtime-provider-event-flow.md index b070ed94..325729cc 100644 --- a/docs/runtime-provider-event-flow.md +++ b/docs/runtime-provider-event-flow.md @@ -11,6 +11,8 @@ - `tool_start` - `tool_chunk` - `tool_result` +- `phase_changed` +- `progress_evaluated` - `stop_reason_decided` - `provider_retry` - `permission_requested` diff --git a/internal/runtime/controlplane/progress.go b/internal/runtime/controlplane/progress.go index 339f530b..a1d26875 100644 --- a/internal/runtime/controlplane/progress.go +++ b/internal/runtime/controlplane/progress.go @@ -27,27 +27,13 @@ type ProgressState struct { } // ApplyProgressEvidence 根据证据更新分值与 streak。 -// 若仅出现 EVIDENCE_NEW_INFO_NON_DUP,则只增加 score_delta,不重置 no_progress_streak(回归约束)。 func ApplyProgressEvidence(state ProgressState, records []ProgressEvidenceRecord) ProgressState { next := state.LastScore if len(records) == 0 { next.NoProgressStreak++ - return ProgressState{LastScore: next} - } - - onlyNonDup := true - for _, r := range records { - if r.Kind != EvidenceNewInfoNonDup { - onlyNonDup = false - break - } - } - if onlyNonDup { + } else { + next.NoProgressStreak = 0 next.ScoreDelta++ - return ProgressState{LastScore: next} } - - next.NoProgressStreak = 0 - next.ScoreDelta++ return ProgressState{LastScore: next} } diff --git a/internal/runtime/controlplane/progress_test.go b/internal/runtime/controlplane/progress_test.go index 7969bf2e..cd25006e 100644 --- a/internal/runtime/controlplane/progress_test.go +++ b/internal/runtime/controlplane/progress_test.go @@ -11,7 +11,7 @@ func TestApplyProgressEvidenceNoEvidenceIncrementsNoProgress(t *testing.T) { } } -func TestApplyProgressEvidenceOnlyNonDupDoesNotResetNoProgressStreak(t *testing.T) { +func TestApplyProgressEvidenceOnlyNonDupResetsNoProgressStreak(t *testing.T) { t.Parallel() state := ProgressState{ LastScore: ProgressScore{NoProgressStreak: 3}, @@ -19,8 +19,8 @@ func TestApplyProgressEvidenceOnlyNonDupDoesNotResetNoProgressStreak(t *testing. next := ApplyProgressEvidence(state, []ProgressEvidenceRecord{ {Kind: EvidenceNewInfoNonDup}, }) - if next.LastScore.NoProgressStreak != 3 { - t.Fatalf("expected streak unchanged at 3, got %d", next.LastScore.NoProgressStreak) + if next.LastScore.NoProgressStreak != 0 { + t.Fatalf("expected streak reset to 0, got %d", next.LastScore.NoProgressStreak) } if next.LastScore.ScoreDelta != 1 { t.Fatalf("expected score_delta 1, got %d", next.LastScore.ScoreDelta) diff --git a/internal/runtime/permission.go b/internal/runtime/permission.go index e9408545..91d53ab6 100644 --- a/internal/runtime/permission.go +++ b/internal/runtime/permission.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strings" + "sync" "time" providertypes "neo-code/internal/provider/types" @@ -164,8 +165,10 @@ func (s *Service) awaitPermissionDecision( input permissionExecutionInput, permissionErr *tools.PermissionDecisionError, ) (approvalflow.Decision, string, error) { - s.permissionAskMu.Lock() - defer s.permissionAskMu.Unlock() + askMu, releaseAskLock := s.acquirePermissionAskLock(input.RunID, input.SessionID) + defer releaseAskLock() + askMu.Lock() + defer askMu.Unlock() requestID, resultCh, err := s.approvalBroker.Open() if err != nil { @@ -195,6 +198,55 @@ func (s *Service) awaitPermissionDecision( } } +// acquirePermissionAskLock 按运行维度获取审批串行锁,避免跨运行的审批互相阻塞。 +func (s *Service) acquirePermissionAskLock(runID string, sessionID string) (*sync.Mutex, func()) { + lockKey := permissionAskLockKey(runID, sessionID) + + s.permissionAskMapMu.Lock() + if s.permissionAskLocks == nil { + s.permissionAskLocks = make(map[string]*permissionAskLockEntry) + } + + entry, ok := s.permissionAskLocks[lockKey] + if !ok { + entry = &permissionAskLockEntry{} + s.permissionAskLocks[lockKey] = entry + } + entry.refs++ + s.permissionAskMapMu.Unlock() + + var releaseOnce sync.Once + release := func() { + releaseOnce.Do(func() { + s.permissionAskMapMu.Lock() + defer s.permissionAskMapMu.Unlock() + + current, exists := s.permissionAskLocks[lockKey] + if !exists || current != entry { + return + } + current.refs-- + if current.refs <= 0 { + delete(s.permissionAskLocks, lockKey) + } + }) + } + return &entry.mu, release +} + +// permissionAskLockKey 生成审批串行锁键,优先按运行隔离,缺失时退化为会话或全局键。 +func permissionAskLockKey(runID string, sessionID string) string { + trimmedRunID := strings.TrimSpace(runID) + if trimmedRunID != "" { + return "run:" + trimmedRunID + } + trimmedSessionID := strings.TrimSpace(sessionID) + if trimmedSessionID != "" { + return "session:" + trimmedSessionID + } + return "global" +} + // emitPermissionResolved 将权限处理结果统一映射为 runtime 事件。 func (s *Service) emitPermissionResolved( ctx context.Context, diff --git a/internal/runtime/permission_test.go b/internal/runtime/permission_test.go index 000eefe4..beef78cb 100644 --- a/internal/runtime/permission_test.go +++ b/internal/runtime/permission_test.go @@ -256,6 +256,86 @@ func TestAwaitPermissionDecisionSerializesConcurrentAskRequests(t *testing.T) { } } +func TestAwaitPermissionDecisionDoesNotSerializeAcrossRuns(t *testing.T) { + t.Parallel() + + service := NewWithFactory( + newRuntimeConfigManager(t), + &stubToolManager{}, + newMemoryStore(), + &scriptedProviderFactory{provider: &scriptedProvider{}}, + nil, + ) + permissionErr := permissionDecisionAskError(t) + + type awaitResult struct { + decision approvalflow.Decision + requestID string + err error + } + resultCh := make(chan awaitResult, 2) + + runAwait := func(runID string, callID string) { + decision, requestID, err := service.awaitPermissionDecision( + context.Background(), + permissionExecutionInput{ + RunID: runID, + SessionID: "session-ask-shared", + Call: providertypes.ToolCall{ + ID: callID, + Name: "filesystem_read_file", + }, + }, + permissionErr, + ) + resultCh <- awaitResult{decision: decision, requestID: requestID, err: err} + } + + go runAwait("run-ask-a", "call-a") + go runAwait("run-ask-b", "call-b") + + requestIDs := make([]string, 0, 2) + for len(requestIDs) < 2 { + select { + case event := <-service.Events(): + if event.Type != EventPermissionRequested { + t.Fatalf("expected permission requested event, got %q", event.Type) + } + payload, ok := event.Payload.(PermissionRequestPayload) + if !ok { + t.Fatalf("expected PermissionRequestPayload, got %#v", event.Payload) + } + requestIDs = append(requestIDs, payload.RequestID) + case <-time.After(500 * time.Millisecond): + t.Fatalf("timed out waiting concurrent permission requests") + } + } + + if requestIDs[0] == requestIDs[1] { + t.Fatalf("expected distinct permission request IDs, got %q", requestIDs[0]) + } + + for _, requestID := range requestIDs { + if err := service.ResolvePermission(context.Background(), PermissionResolutionInput{ + RequestID: requestID, + Decision: approvalflow.DecisionAllowOnce, + }); err != nil { + t.Fatalf("ResolvePermission(%q) error = %v", requestID, err) + } + } + + for i := 0; i < 2; i++ { + select { + case res := <-resultCh: + if res.err != nil { + t.Fatalf("awaitPermissionDecision() error = %v", res.err) + } + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting awaitPermissionDecision result") + } + } +} + func TestServiceRunPermissionRejectFlow(t *testing.T) { t.Parallel() diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 96671d52..0f456d84 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -67,14 +67,15 @@ type Service struct { approvalBroker *approval.Broker memoExtractor MemoExtractor - events chan RuntimeEvent - sessionMu sync.Mutex - sessionLocks map[string]*sessionLockEntry - runMu sync.Mutex - activeRunToken uint64 - nextRunToken uint64 - activeRunCancels map[uint64]context.CancelFunc - permissionAskMu sync.Mutex + events chan RuntimeEvent + sessionMu sync.Mutex + sessionLocks map[string]*sessionLockEntry + runMu sync.Mutex + activeRunToken uint64 + nextRunToken uint64 + activeRunCancels map[uint64]context.CancelFunc + permissionAskMapMu sync.Mutex + permissionAskLocks map[string]*permissionAskLockEntry } // sessionLockEntry 维护单个会话锁及其当前引用计数,用于在无引用时回收 map 项。 @@ -83,6 +84,12 @@ type sessionLockEntry struct { refs int } +// permissionAskLockEntry 维护单个运行的审批串行锁与引用计数。 +type permissionAskLockEntry struct { + mu sync.Mutex + refs int +} + // NewWithFactory 使用注入依赖构建默认 runtime Service。 func NewWithFactory( configManager *config.Manager, @@ -102,15 +109,16 @@ func NewWithFactory( } return &Service{ - configManager: configManager, - sessionStore: sessionStore, - toolManager: toolManager, - providerFactory: providerFactory, - contextBuilder: contextBuilder, - approvalBroker: approval.NewBroker(), - events: make(chan RuntimeEvent, 128), - sessionLocks: make(map[string]*sessionLockEntry), - activeRunCancels: make(map[uint64]context.CancelFunc), + configManager: configManager, + sessionStore: sessionStore, + toolManager: toolManager, + providerFactory: providerFactory, + contextBuilder: contextBuilder, + approvalBroker: approval.NewBroker(), + events: make(chan RuntimeEvent, 128), + sessionLocks: make(map[string]*sessionLockEntry), + permissionAskLocks: make(map[string]*permissionAskLockEntry), + activeRunCancels: make(map[uint64]context.CancelFunc), } } diff --git a/internal/runtime/runtime_progress_test.go b/internal/runtime/runtime_progress_test.go index d02deb80..f3fc6811 100644 --- a/internal/runtime/runtime_progress_test.go +++ b/internal/runtime/runtime_progress_test.go @@ -3,6 +3,7 @@ package runtime import ( "context" "errors" + "sync/atomic" "testing" "neo-code/internal/config" @@ -84,3 +85,59 @@ func TestProgressStreakStopsRun(t *testing.T) { } } } + +func TestProgressEvidenceResetsNoProgressStreak(t *testing.T) { + t.Setenv("TEST_KEY", "dummy") + + cfg := config.Config{ + Providers: []config.ProviderConfig{{Name: "test-progress", Driver: "test", BaseURL: "http://localhost", Model: "test", APIKeyEnv: "TEST_KEY"}}, + SelectedProvider: "test-progress", + MaxLoops: 5, + Workdir: t.TempDir(), + } + + var executeCalls int32 + toolManager := &stubToolManager{ + specs: []providertypes.ToolSpec{ + {Name: "tool_mixed"}, + }, + executeFn: func(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + call := int(atomic.AddInt32(&executeCalls, 1)) + if call == 3 { + return tools.ToolResult{Name: input.Name, Content: "ok", IsError: false}, nil + } + return tools.ToolResult{Name: input.Name, Content: "error occurred", IsError: true}, nil + }, + } + + providerFactory := &scriptedProviderFactory{ + provider: &scriptedProvider{ + chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { + events <- providertypes.NewToolCallStartStreamEvent(0, "call_mixed", "tool_mixed") + events <- providertypes.NewToolCallDeltaStreamEvent(0, "call_mixed", "{}") + events <- providertypes.NewMessageDoneStreamEvent("tool_calls", nil) + return nil + }, + }, + } + + manager := config.NewManager(config.NewLoader(t.TempDir(), &cfg)) + service := NewWithFactory( + manager, + toolManager, + newMemoryStore(), + providerFactory, + nil, + ) + + err := service.Run(context.Background(), UserInput{ + RunID: "run-progress-reset", + Content: "trigger mixed progress loop", + }) + if !errors.Is(err, ErrMaxLoopReached) { + t.Fatalf("expected ErrMaxLoopReached, got %v", err) + } + if errors.Is(err, ErrNoProgressStreakLimit) { + t.Fatalf("expected not to hit no-progress streak limit, got %v", err) + } +} diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 397041aa..4a0f4918 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -333,6 +333,7 @@ func (b *stubContextBuilder) Build(ctx context.Context, input agentcontext.Build } type stubToolManager struct { + mu sync.Mutex specs []providertypes.ToolSpec result tools.ToolResult err error @@ -351,6 +352,8 @@ type stubToolManager struct { } func (m *stubToolManager) ListAvailableSpecs(ctx context.Context, input tools.SpecListInput) ([]providertypes.ToolSpec, error) { + m.mu.Lock() + defer m.mu.Unlock() m.listCalls++ if err := ctx.Err(); err != nil { return nil, err @@ -362,6 +365,8 @@ func (m *stubToolManager) ListAvailableSpecs(ctx context.Context, input tools.Sp } func (m *stubToolManager) MicroCompactPolicy(name string) tools.MicroCompactPolicy { + m.mu.Lock() + defer m.mu.Unlock() if policy, ok := m.policies[name]; ok { return policy } @@ -369,19 +374,25 @@ func (m *stubToolManager) MicroCompactPolicy(name string) tools.MicroCompactPoli } func (m *stubToolManager) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + m.mu.Lock() m.executeCalls++ m.lastInput = input - if m.executeFn != nil { - return m.executeFn(ctx, input) - } + executeFn := m.executeFn result := m.result + err := m.err + m.mu.Unlock() + if executeFn != nil { + return executeFn(ctx, input) + } if result.Name == "" { result.Name = input.Name } - return result, m.err + return result, err } func (m *stubToolManager) RememberSessionDecision(sessionID string, action security.Action, scope tools.SessionPermissionScope) error { + m.mu.Lock() + defer m.mu.Unlock() m.remembered = append(m.remembered, struct { sessionID string action security.Action From 473ede17a2cc64d69d42c825f109f7c356ea3636 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Tue, 14 Apr 2026 11:29:41 +0000 Subject: [PATCH 4/9] fix(runtime): tighten tool execution scheduling and docs alignment Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- docs/context-compact.md | 1 + internal/runtime/run.go | 2 +- internal/runtime/runtime.go | 1 + internal/runtime/toolexec.go | 183 +++++++++++++++++++++-------------- 4 files changed, 113 insertions(+), 74 deletions(-) diff --git a/docs/context-compact.md b/docs/context-compact.md index be431119..8034378c 100644 --- a/docs/context-compact.md +++ b/docs/context-compact.md @@ -145,6 +145,7 @@ compact 相关 runtime 事件包括: - `applied` - `before_chars` +- `before_tokens` - `after_chars` - `saved_ratio` - `trigger_mode` diff --git a/internal/runtime/run.go b/internal/runtime/run.go index 69052e6c..8158d895 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -161,7 +161,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { s.emitRunScoped(ctx, EventProgressEvaluated, &state, ProgressEvaluatedPayload{Score: currentScore}) - if streak >= 3 { + if streak >= noProgressStreakLimit { err = ErrNoProgressStreakLimit return err } diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 0f456d84..6ffa1114 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -21,6 +21,7 @@ const ( providerRetryMaxWait = 5 * time.Second defaultMaxLoops = 8 defaultToolParallelism = 4 + noProgressStreakLimit = 3 terminationEventEmitTimeout = 500 * time.Millisecond ) diff --git a/internal/runtime/toolexec.go b/internal/runtime/toolexec.go index 8067c286..dd013a5f 100644 --- a/internal/runtime/toolexec.go +++ b/internal/runtime/toolexec.go @@ -20,11 +20,13 @@ func (s *Service) executeAssistantToolCalls( return nil } - var wg sync.WaitGroup + parallelism := resolveToolParallelism(len(assistant.ToolCalls)) + orderedCalls := reorderToolCallsByNameRoundRobin(assistant.ToolCalls) + toolLocks := buildToolExecutionLocks(assistant.ToolCalls) + taskCh := make(chan providertypes.ToolCall) var mu sync.Mutex var firstErr error - semaphore := make(chan struct{}, resolveToolParallelism(len(assistant.ToolCalls))) - toolLocks := buildToolExecutionLocks(assistant.ToolCalls) + var workerWG sync.WaitGroup checkContext := func() bool { if err := ctx.Err(); err != nil { @@ -34,75 +36,91 @@ func (s *Service) executeAssistantToolCalls( return false } - for _, call := range assistant.ToolCalls { - if err := ctx.Err(); err != nil { - return err - } - wg.Add(1) - go func(call providertypes.ToolCall, toolLock *sync.Mutex) { - defer wg.Done() - if checkContext() { - return + for i := 0; i < parallelism; i++ { + workerWG.Add(1) + go func() { + defer workerWG.Done() + for call := range taskCh { + s.executeOneToolCall(ctx, state, snapshot, call, toolLocks[normalizeToolLockKey(call.Name)], checkContext, &mu, &firstErr) } - if err := acquireToolSlot(ctx, semaphore); err != nil { - rememberFirstError(&mu, &firstErr, err) - return - } - defer releaseToolSlot(semaphore) + }() + } + + for _, call := range orderedCalls { + if checkContext() { + break + } + taskCh <- call + } - toolLock.Lock() - defer toolLock.Unlock() + close(taskCh) + workerWG.Wait() + return firstErr +} - s.emitRunScoped(ctx, EventToolStart, state, call) +// executeOneToolCall 在单个 worker 中执行一次工具调用并处理结果回写与事件发射。 +func (s *Service) executeOneToolCall( + ctx context.Context, + state *runState, + snapshot turnSnapshot, + call providertypes.ToolCall, + toolLock *sync.Mutex, + checkContext func() bool, + mu *sync.Mutex, + firstErr *error, +) { + if checkContext() { + return + } - result, execErr := s.executeToolCallWithPermission(ctx, permissionExecutionInput{ - RunID: state.runID, - SessionID: state.session.ID, - Call: call, - Workdir: snapshot.workdir, - ToolTimeout: snapshot.toolTimeout, - }) + toolLock.Lock() + defer toolLock.Unlock() - if errors.Is(execErr, context.Canceled) { - rememberFirstError(&mu, &firstErr, execErr) - return - } - if execErr == nil && checkContext() { - return - } + s.emitRunScoped(ctx, EventToolStart, state, call) - if execErr != nil && strings.TrimSpace(result.Content) == "" { - result.Content = execErr.Error() - } + result, execErr := s.executeToolCallWithPermission(ctx, permissionExecutionInput{ + RunID: state.runID, + SessionID: state.session.ID, + Call: call, + Workdir: snapshot.workdir, + ToolTimeout: snapshot.toolTimeout, + }) - if err := s.appendToolMessageAndSave(ctx, state, call, result); err != nil { - if execErr != nil && errors.Is(err, context.Canceled) { - s.emitRunScoped(ctx, EventToolResult, state, result) - } - rememberFirstError(&mu, &firstErr, err) - return - } + if errors.Is(execErr, context.Canceled) { + rememberFirstError(mu, firstErr, execErr) + return + } + if execErr == nil && checkContext() { + return + } - if execErr == nil && checkContext() { - return - } + if execErr != nil && strings.TrimSpace(result.Content) == "" { + result.Content = execErr.Error() + } + if err := s.appendToolMessageAndSave(ctx, state, call, result); err != nil { + if execErr != nil && errors.Is(err, context.Canceled) { s.emitRunScoped(ctx, EventToolResult, state, result) + } + rememberFirstError(mu, firstErr, err) + return + } - if isSuccessfulRememberToolCall(call.Name, result, execErr) { - state.mu.Lock() - state.rememberedThisRun = true - state.mu.Unlock() - } + if execErr == nil && checkContext() { + return + } - if execErr != nil && checkContext() { - return - } - }(call, toolLocks[normalizeToolLockKey(call.Name)]) + s.emitRunScoped(ctx, EventToolResult, state, result) + + if isSuccessfulRememberToolCall(call.Name, result, execErr) { + state.mu.Lock() + state.rememberedThisRun = true + state.mu.Unlock() } - wg.Wait() - return firstErr + if execErr != nil && checkContext() { + return + } } // resolveToolParallelism 计算本轮工具执行的并发上限,避免无界 goroutine 扩散。 @@ -116,6 +134,40 @@ func resolveToolParallelism(toolCallCount int) int { return defaultToolParallelism } +// reorderToolCallsByNameRoundRobin 按工具名分组后轮询展开,降低同名批量调用导致的队头阻塞。 +func reorderToolCallsByNameRoundRobin(calls []providertypes.ToolCall) []providertypes.ToolCall { + if len(calls) <= 1 { + return append([]providertypes.ToolCall(nil), calls...) + } + grouped := make(map[string][]providertypes.ToolCall, len(calls)) + order := make([]string, 0, len(calls)) + for _, call := range calls { + key := normalizeToolLockKey(call.Name) + if _, ok := grouped[key]; !ok { + order = append(order, key) + } + grouped[key] = append(grouped[key], call) + } + + ordered := make([]providertypes.ToolCall, 0, len(calls)) + for { + progressed := false + for _, key := range order { + queue := grouped[key] + if len(queue) == 0 { + continue + } + ordered = append(ordered, queue[0]) + grouped[key] = queue[1:] + progressed = true + } + if !progressed { + break + } + } + return ordered +} + // buildToolExecutionLocks 按工具名构造互斥锁,确保同名工具调用在单轮内串行执行。 func buildToolExecutionLocks(calls []providertypes.ToolCall) map[string]*sync.Mutex { locks := make(map[string]*sync.Mutex, len(calls)) @@ -133,21 +185,6 @@ func normalizeToolLockKey(name string) string { return strings.ToLower(strings.TrimSpace(name)) } -// acquireToolSlot 获取一个并发槽位,并在上下文取消时尽快返回。 -func acquireToolSlot(ctx context.Context, semaphore chan struct{}) error { - select { - case semaphore <- struct{}{}: - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -// releaseToolSlot 释放工具执行并发槽位。 -func releaseToolSlot(semaphore chan struct{}) { - <-semaphore -} - // rememberFirstError 记录首次错误,后续错误只保留用于日志和事件路径。 func rememberFirstError(mu *sync.Mutex, firstErr *error, err error) { if err == nil { From cd2f5d6b74115d0137406e875d11218d4637121d Mon Sep 17 00:00:00 2001 From: xgopilot Date: Tue, 14 Apr 2026 11:57:25 +0000 Subject: [PATCH 5/9] fix(runtime): address unresolved review findings and simplify paths Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- docs/guides/configuration.md | 2 +- .../runtime/runtime_internal_helpers_test.go | 52 ++++++++++++++ internal/runtime/runtime_test.go | 68 +++++++++++++++++++ internal/runtime/session_mutation.go | 53 ++++++++++++++- internal/runtime/toolexec.go | 51 ++++++++++---- 5 files changed, 211 insertions(+), 15 deletions(-) diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index f7beab33..f43de0ec 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -35,7 +35,7 @@ custom provider 目录: ```yaml selected_provider: openai -current_model: gpt-4.1 +current_model: gpt-5.4 shell: bash max_loops: 8 tool_timeout_sec: 20 diff --git a/internal/runtime/runtime_internal_helpers_test.go b/internal/runtime/runtime_internal_helpers_test.go index bb772baf..71314a6f 100644 --- a/internal/runtime/runtime_internal_helpers_test.go +++ b/internal/runtime/runtime_internal_helpers_test.go @@ -22,6 +22,25 @@ type stubMemoExtractor struct { doneCh chan struct{} } +type lockProbeStore struct { + saveFn func(ctx context.Context, session *agentsession.Session) error +} + +func (s *lockProbeStore) Save(ctx context.Context, session *agentsession.Session) error { + if s.saveFn == nil { + return nil + } + return s.saveFn(ctx, session) +} + +func (s *lockProbeStore) Load(ctx context.Context, id string) (agentsession.Session, error) { + return agentsession.Session{}, errors.New("not implemented") +} + +func (s *lockProbeStore) ListSummaries(ctx context.Context) ([]agentsession.Summary, error) { + return nil, errors.New("not implemented") +} + func (s *stubMemoExtractor) Schedule(_ string, messages []providertypes.Message) { s.mu.Lock() s.calls++ @@ -137,6 +156,39 @@ func TestAppendToolMessageAndSaveSanitizesMetadata(t *testing.T) { } } +func TestAppendToolMessageAndSaveUnlocksStateBeforePersist(t *testing.T) { + t.Parallel() + + session := newRuntimeSession("session-append-tool-lock") + state := newRunState("run-append-tool-lock", session) + + store := &lockProbeStore{ + saveFn: func(_ context.Context, _ *agentsession.Session) error { + locked := make(chan struct{}) + go func() { + state.mu.Lock() + state.mu.Unlock() + close(locked) + }() + + select { + case <-locked: + return nil + case <-time.After(200 * time.Millisecond): + return errors.New("state lock is still held during save") + } + }, + } + + service := &Service{sessionStore: store} + call := providertypes.ToolCall{ID: "call-1", Name: "filesystem_read_file"} + result := tools.ToolResult{Name: "filesystem_read_file", Content: "ok"} + + if err := service.appendToolMessageAndSave(context.Background(), &state, call, result); err != nil { + t.Fatalf("appendToolMessageAndSave() error = %v", err) + } +} + func TestResolveMaxLoopsBranches(t *testing.T) { t.Parallel() diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 4a0f4918..9bfa09fb 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -4483,6 +4483,74 @@ func TestParallelToolCallsSerializeSameToolName(t *testing.T) { } } +func TestParallelToolCallsStopDispatchAfterFirstError(t *testing.T) { + t.Parallel() + + const ( + totalCalls = 12 + slowToolDelay = 200 * time.Millisecond + ) + + toolSpecs := make([]providertypes.ToolSpec, 0, totalCalls) + toolCalls := make([]providertypes.ToolCall, 0, totalCalls) + for i := 0; i < totalCalls; i++ { + name := fmt.Sprintf("tool_%d", i) + toolSpecs = append(toolSpecs, providertypes.ToolSpec{Name: name}) + toolCalls = append(toolCalls, providertypes.ToolCall{ID: fmt.Sprintf("call_%d", i), Name: name, Arguments: `{}`}) + } + + var executeStarted int32 + toolManager := &stubToolManager{ + specs: toolSpecs, + executeFn: func(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + atomic.AddInt32(&executeStarted, 1) + if input.Name == "tool_0" { + return tools.ToolResult{Name: input.Name, Content: "ok"}, nil + } + time.Sleep(slowToolDelay) + return tools.ToolResult{Name: input.Name, Content: "ok"}, nil + }, + } + + providerFactory := &scriptedProviderFactory{ + provider: &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + Role: providertypes.RoleAssistant, + ToolCalls: toolCalls, + }, + FinishReason: "tool_calls", + }, + }, + }, + } + + baseStore := newMemoryStore() + store := &failingStore{ + Store: baseStore, + saveErr: errors.New("save failed"), + failOnSave: 3, + } + + service := NewWithFactory( + newRuntimeConfigManager(t), + toolManager, + store, + providerFactory, + nil, + ) + + err := service.Run(context.Background(), UserInput{RunID: "run-first-error-stop-dispatch", Content: "parallel"}) + if err == nil { + t.Fatalf("expected run error when first tool result save fails") + } + + if got := atomic.LoadInt32(&executeStarted); got >= int32(totalCalls) { + t.Fatalf("expected dispatch to stop before all tool calls start, started=%d total=%d", got, totalCalls) + } +} + func TestAgentDoneEventCarriesRunScopedEnvelope(t *testing.T) { t.Parallel() diff --git a/internal/runtime/session_mutation.go b/internal/runtime/session_mutation.go index cdd3dd15..88a0f2b8 100644 --- a/internal/runtime/session_mutation.go +++ b/internal/runtime/session_mutation.go @@ -5,6 +5,7 @@ import ( "strings" providertypes "neo-code/internal/provider/types" + agentsession "neo-code/internal/session" "neo-code/internal/tools" ) @@ -54,7 +55,6 @@ func (s *Service) appendToolMessageAndSave( result tools.ToolResult, ) error { state.mu.Lock() - defer state.mu.Unlock() toolMessage := providertypes.Message{ Role: providertypes.RoleTool, Content: result.Content, @@ -64,5 +64,54 @@ func (s *Service) appendToolMessageAndSave( } state.session.Messages = append(state.session.Messages, toolMessage) state.touchSession() - return s.sessionStore.Save(ctx, &state.session) + sessionSnapshot := cloneSessionForPersistence(state.session) + state.mu.Unlock() + return s.sessionStore.Save(ctx, &sessionSnapshot) +} + +// cloneSessionForPersistence 复制会话快照,避免持久化阶段与并发写入共享可变切片/映射。 +func cloneSessionForPersistence(session agentsession.Session) agentsession.Session { + cloned := session + cloned.Messages = cloneMessagesForPersistence(session.Messages) + cloned.TaskState = session.TaskState.Clone() + cloned.Todos = cloneTodosForPersistence(session.Todos) + return cloned +} + +// cloneMessagesForPersistence 深拷贝消息切片及其嵌套字段,确保 Save 期间读取稳定。 +func cloneMessagesForPersistence(messages []providertypes.Message) []providertypes.Message { + if len(messages) == 0 { + return nil + } + cloned := make([]providertypes.Message, len(messages)) + for idx, message := range messages { + next := message + if len(message.ToolCalls) > 0 { + next.ToolCalls = append([]providertypes.ToolCall(nil), message.ToolCalls...) + } else { + next.ToolCalls = nil + } + if len(message.ToolMetadata) > 0 { + next.ToolMetadata = make(map[string]string, len(message.ToolMetadata)) + for key, value := range message.ToolMetadata { + next.ToolMetadata[key] = value + } + } else { + next.ToolMetadata = nil + } + cloned[idx] = next + } + return cloned +} + +// cloneTodosForPersistence 深拷贝 Todo 列表,确保持久化快照不与运行态共享底层切片。 +func cloneTodosForPersistence(todos []agentsession.TodoItem) []agentsession.TodoItem { + if len(todos) == 0 { + return nil + } + cloned := make([]agentsession.TodoItem, len(todos)) + for idx, item := range todos { + cloned[idx] = item.Clone() + } + return cloned } diff --git a/internal/runtime/toolexec.go b/internal/runtime/toolexec.go index dd013a5f..042104ce 100644 --- a/internal/runtime/toolexec.go +++ b/internal/runtime/toolexec.go @@ -20,6 +20,9 @@ func (s *Service) executeAssistantToolCalls( return nil } + execCtx, cancelExec := context.WithCancel(ctx) + defer cancelExec() + parallelism := resolveToolParallelism(len(assistant.ToolCalls)) orderedCalls := reorderToolCallsByNameRoundRobin(assistant.ToolCalls) toolLocks := buildToolExecutionLocks(assistant.ToolCalls) @@ -29,11 +32,7 @@ func (s *Service) executeAssistantToolCalls( var workerWG sync.WaitGroup checkContext := func() bool { - if err := ctx.Err(); err != nil { - rememberFirstError(&mu, &firstErr, err) - return true - } - return false + return shouldStopToolExecution(&mu, &firstErr, execCtx.Err()) } for i := 0; i < parallelism; i++ { @@ -41,7 +40,17 @@ func (s *Service) executeAssistantToolCalls( go func() { defer workerWG.Done() for call := range taskCh { - s.executeOneToolCall(ctx, state, snapshot, call, toolLocks[normalizeToolLockKey(call.Name)], checkContext, &mu, &firstErr) + s.executeOneToolCall( + execCtx, + state, + snapshot, + call, + toolLocks[normalizeToolLockKey(call.Name)], + checkContext, + func(err error) { + recordAndCancelOnFirstError(&mu, &firstErr, err, cancelExec) + }, + ) } }() } @@ -66,8 +75,7 @@ func (s *Service) executeOneToolCall( call providertypes.ToolCall, toolLock *sync.Mutex, checkContext func() bool, - mu *sync.Mutex, - firstErr *error, + rememberError func(error), ) { if checkContext() { return @@ -87,7 +95,7 @@ func (s *Service) executeOneToolCall( }) if errors.Is(execErr, context.Canceled) { - rememberFirstError(mu, firstErr, execErr) + rememberError(execErr) return } if execErr == nil && checkContext() { @@ -102,7 +110,7 @@ func (s *Service) executeOneToolCall( if execErr != nil && errors.Is(err, context.Canceled) { s.emitRunScoped(ctx, EventToolResult, state, result) } - rememberFirstError(mu, firstErr, err) + rememberError(err) return } @@ -186,13 +194,32 @@ func normalizeToolLockKey(name string) string { } // rememberFirstError 记录首次错误,后续错误只保留用于日志和事件路径。 -func rememberFirstError(mu *sync.Mutex, firstErr *error, err error) { +func rememberFirstError(mu *sync.Mutex, firstErr *error, err error) bool { if err == nil { - return + return false } mu.Lock() defer mu.Unlock() if *firstErr == nil { *firstErr = err + return true + } + return false +} + +// shouldStopToolExecution 统一判断工具执行是否应停止,并在上下文取消时兜底记录错误原因。 +func shouldStopToolExecution(mu *sync.Mutex, firstErr *error, contextErr error) bool { + mu.Lock() + defer mu.Unlock() + if contextErr != nil && *firstErr == nil { + *firstErr = contextErr + } + return *firstErr != nil +} + +// recordAndCancelOnFirstError 在首次记录错误时触发执行上下文取消,阻止后续工具继续派发。 +func recordAndCancelOnFirstError(mu *sync.Mutex, firstErr *error, err error, cancel context.CancelFunc) { + if rememberFirstError(mu, firstErr, err) { + cancel() } } From 269f83d6bd9c185198dc951bcc9bef7121e7f3bc Mon Sep 17 00:00:00 2001 From: xgopilot Date: Tue, 14 Apr 2026 13:04:57 +0000 Subject: [PATCH 6/9] fix(runtime): repair runtime tests compile errors Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/runtime/runtime_test.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 230b4099..a79ef3d7 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -1993,13 +1993,13 @@ func TestServiceRunMaxLoopsSavesLoopLimitCheckpoint(t *testing.T) { EventToolChunk, EventToolResult, EventCompactStart, - EventCompactDone, + EventCompactApplied, EventError, }) assertNoEventType(t, events, EventCompactError) compactStartIndex := eventIndex(events, EventCompactStart) - compactDoneIndex := eventIndex(events, EventCompactDone) + compactDoneIndex := eventIndex(events, EventCompactApplied) errorIndex := eventIndex(events, EventError) if compactStartIndex == -1 || compactDoneIndex == -1 || errorIndex == -1 { t.Fatalf("expected loop-limit events in %+v", events) @@ -2012,7 +2012,7 @@ func TestServiceRunMaxLoopsSavesLoopLimitCheckpoint(t *testing.T) { foundLoopLimitError := false for _, event := range events { switch event.Type { - case EventCompactDone: + case EventCompactApplied: payload, ok := event.Payload.(CompactResult) if !ok { t.Fatalf("expected CompactResult payload, got %T", event.Payload) @@ -2115,7 +2115,7 @@ func TestServiceRunMaxLoopsLoopLimitCompactFailureFallsBackToOriginalExit(t *tes EventCompactError, EventError, }) - assertNoEventType(t, events, EventCompactDone) + assertNoEventType(t, events, EventCompactApplied) compactStartIndex := eventIndex(events, EventCompactStart) compactErrorIndex := eventIndex(events, EventCompactError) @@ -3214,6 +3214,8 @@ func collectRuntimeEvents(events <-chan RuntimeEvent) []RuntimeEvent { // isPermissionRequestEvent 判断是否为权限请求类事件(含 1A 主事件与兼容旧名)。 func isPermissionRequestEvent(typ EventType) bool { return typ == EventPermissionRequested +} + func eventIndex(events []RuntimeEvent, want EventType) int { for index, event := range events { if event.Type == want { From 5d2a94f935b97c0ba3a2bd4eec6a48706d63f2bd Mon Sep 17 00:00:00 2001 From: xgopilot Date: Tue, 14 Apr 2026 13:27:16 +0000 Subject: [PATCH 7/9] fix(runtime): preserve ErrMaxLoopReached sentinel on loop-limit exit Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/runtime/run.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/internal/runtime/run.go b/internal/runtime/run.go index 63e85162..779405c2 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -79,7 +79,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { for turn := 0; ; turn++ { maxLoops := resolveMaxLoops(s.configManager.Get()) if turn >= maxLoops { - errMessage := "runtime: max loop reached" + loopErr := ErrMaxLoopReached applied, compactErr := s.applyCompactForState( ctx, &state, @@ -91,11 +91,10 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { return s.handleRunError(ctx, state.runID, state.session.ID, compactErr) } if applied { - errMessage = "runtime: max loop reached after saving continuation checkpoint" + loopErr = fmt.Errorf("%w after saving continuation checkpoint", ErrMaxLoopReached) } - err := errors.New(errMessage) - s.emit(ctx, EventError, state.runID, state.session.ID, err.Error()) - return err + s.emit(ctx, EventError, state.runID, state.session.ID, loopErr.Error()) + return loopErr } state.turn = turn From 50df6ceecedc2125832e4801cd0dde2679bfb5ca Mon Sep 17 00:00:00 2001 From: xgopilot Date: Tue, 14 Apr 2026 15:14:41 +0000 Subject: [PATCH 8/9] fix(runtime): resolve conflicts with origin/main semantics Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- docs/config-management-detail-design.md | 2 - docs/context-compact.md | 14 +- docs/guides/configuration.md | 2 - docs/runtime-provider-event-flow.md | 8 +- internal/runtime/run.go | 30 +- internal/runtime/runtime.go | 1 - .../runtime/runtime_internal_helpers_test.go | 15 - internal/runtime/runtime_progress_test.go | 36 +- internal/runtime/runtime_test.go | 340 ++---------------- 9 files changed, 73 insertions(+), 375 deletions(-) diff --git a/docs/config-management-detail-design.md b/docs/config-management-detail-design.md index 1d41e13c..e7ef7836 100644 --- a/docs/config-management-detail-design.md +++ b/docs/config-management-detail-design.md @@ -44,7 +44,6 @@ - `workdir` - `shell` -- `max_loops` - `tool_timeout_sec` - `context` - `tools` @@ -100,7 +99,6 @@ custom provider 来自: - `selected_provider` - `current_model` - `shell` -- `max_loops` - `tool_timeout_sec` - `context` - `tools` diff --git a/docs/context-compact.md b/docs/context-compact.md index dd8cde69..87fded85 100644 --- a/docs/context-compact.md +++ b/docs/context-compact.md @@ -4,8 +4,8 @@ ## 概览 -- runtime 已接入手动 compact、基于 token 阈值的自动 compact、provider 上下文过长后的 `reactive` compact 自动恢复,以及命中最大轮数前的 `loop_limit` continuation checkpoint。 -- `internal/context/compact` 支持 `manual`、`auto`、`reactive` 与 `loop_limit` 四种 mode。 +- runtime 已接入手动 compact、基于 token 阈值的自动 compact,以及 provider 上下文过长后的 `reactive` compact 自动恢复。 +- `internal/context/compact` 支持 `manual`、`auto` 与 `reactive` 三种 mode。 - 用户通过 `/compact` 对当前会话执行一次上下文压缩。 - compact 前会先写入完整 transcript,随后生成并校验新的 durable `TaskState` 与 display summary,再回写会话消息。 @@ -76,15 +76,9 @@ context: 当 provider 返回“上下文过长”错误时,runtime 会: 1. 识别 provider 归一化后的 typed error,必要时回退到错误文本匹配。 -2. 触发一次 `compact.Run(mode=reactive)`。 +2. 触发 `compact.Run(mode=reactive)`,并在仍然命中“上下文过长”时继续做逐步降级恢复。 3. 继续复用 `compact_start`、`compact_applied`、`compact_error` 事件,并通过 `trigger_mode=reactive` 区分来源。 -4. 每次 `Run()` 最多只执行一次 reactive 重试,避免无限循环。 - -当 runtime 命中 `max_loops` 停止条件时,还会在返回最终错误前 best-effort 触发一次 `compact.Run(mode=loop_limit)` 作为 continuation checkpoint: - -1. 成功时回写 compact 后的消息、durable `TaskState` 与重置后的 token 计数。 -2. 继续复用 `compact_start`、`compact_done`、`compact_error` 事件,并通过 `trigger_mode=loop_limit` 标记来源。 -3. 无论 compact 成功、失败或 no-op,本次 run 仍会结束;区别只在于后续“继续”能否优先复用 durable checkpoint。 +4. 每次 `Run()` 最多执行 3 次 reactive compact 降级尝试;每次尝试都会进一步收缩 `manual_keep_recent_messages`,超过上限后返回最后一次 provider 错误。 ## 生成协议 diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 83cbeb6b..b2a407c0 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -37,7 +37,6 @@ custom provider 目录: selected_provider: openai current_model: gpt-5.4 shell: bash -max_loops: 8 tool_timeout_sec: 20 tools: @@ -67,7 +66,6 @@ context: | `selected_provider` | 当前选中的 provider 名称 | | `current_model` | 当前选中的模型 ID | | `shell` | 默认 shell,Windows 默认 `powershell`,其他平台默认 `bash` | -| `max_loops` | Agent 主循环最大轮数 | | `tool_timeout_sec` | 工具执行超时(秒) | ### `context` 字段 diff --git a/docs/runtime-provider-event-flow.md b/docs/runtime-provider-event-flow.md index 32e03caf..9f8d37ff 100644 --- a/docs/runtime-provider-event-flow.md +++ b/docs/runtime-provider-event-flow.md @@ -22,7 +22,7 @@ - `compact_applied` - `compact_error` -这三类 compact 事件同时用于 `manual`、`auto`、`reactive` 与 `loop_limit` 四种来源,调用方可通过 payload 中的 `trigger_mode` 区分。 +这三类 compact 事件同时用于 `manual`、`auto` 与 `reactive` 三种来源,调用方可通过 payload 中的 `trigger_mode` 区分。 ## ReAct 主循环 @@ -33,15 +33,15 @@ 5. 如命中 token 阈值自动压缩建议,则先执行一次 compact,再在同一轮内重建请求。 6. 冻结当前 turn 的 `provider / model / tools / workdir / request` 快照。 7. 调用 `Provider.Generate`,并把流式事件桥接给 TUI。 -8. 如 provider 返回“上下文过长”错误,则触发一次 `reactive` compact,并仅在同一 turn 内重建一次当前请求。 +8. 如 provider 返回“上下文过长”错误,则触发 `reactive` compact,并在同一 run 内最多做 3 次逐步降级的恢复尝试。 9. 保存 assistant 完整回复。 10. 执行返回的工具调用,并保存每一个工具结果。 11. 如果最终 assistant 回复后没有后续工具调用,则在 runtime 收口处安排一次后台 memo 自动提取。 12. 如果仍需继续推理,则进入下一轮;否则结束。 补充说明: -- 当下一轮开始前已命中 `max_loops` 上限时,runtime 会先尝试一次 `loop_limit` compact checkpoint,再发出最终 `error` 事件结束本次 run。 -- 该 checkpoint 成功时会先发出 `compact_start -> compact_done`,并把 session 消息、`TaskState` 与累计 token 重置后的状态持久化;失败时会发出 `compact_error`,随后仍以最大轮数错误结束。 +- runtime 不再设置内部 `max_loops` 停止条件;单次 run 仅在拿到最终 assistant 回复、遇到错误或收到外部取消时结束。 +- 由于 session 锁覆盖整个 run 生命周期,同一会话如果持续陷入工具调用循环,会一直占用该会话直到模型自行收口、报错或被取消。 ### Memo 自动提取调度 diff --git a/internal/runtime/run.go b/internal/runtime/run.go index 779405c2..ba5c13f8 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -21,6 +21,8 @@ import ( // Run 执行一次完整的 ReAct 闭环:保存用户输入、驱动模型、执行工具并发出事件。 // 已有会话会先加锁再加载/更新,确保同一会话并发 Run 不会出现状态覆盖; // 新会话在创建后再绑定会话锁,不同会话可并行执行。 +// 当前实现不再设置内部轮数上限,因此 Run 仅在拿到最终 assistant 回复、遇到错误或收到外部取消时结束。 +// 这也意味着同一 session 的锁会覆盖整个运行周期,调用方需要依赖模型终止条件或取消机制兜底。 func (s *Service) Run(ctx context.Context, input UserInput) (err error) { var statePtr *runState @@ -77,26 +79,6 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { } for turn := 0; ; turn++ { - maxLoops := resolveMaxLoops(s.configManager.Get()) - if turn >= maxLoops { - loopErr := ErrMaxLoopReached - applied, compactErr := s.applyCompactForState( - ctx, - &state, - s.configManager.Get(), - contextcompact.ModeLoopLimit, - compactErrorBestEffort, - ) - if compactErr != nil { - return s.handleRunError(ctx, state.runID, state.session.ID, compactErr) - } - if applied { - loopErr = fmt.Errorf("%w after saving continuation checkpoint", ErrMaxLoopReached) - } - s.emit(ctx, EventError, state.runID, state.session.ID, loopErr.Error()) - return loopErr - } - state.turn = turn s.transitionRunPhase(ctx, &state, controlplane.PhasePlan) @@ -343,14 +325,6 @@ func (s *Service) applyCompactForState( return false, nil } -// resolveMaxLoops 收敛运行时最大推理轮数的默认值逻辑。 -func resolveMaxLoops(cfg config.Config) int { - if cfg.MaxLoops <= 0 { - return defaultMaxLoops - } - return cfg.MaxLoops -} - // autoCompactThreshold 返回当前配置下的自动 compact 触发阈值。 func autoCompactThreshold(cfg config.Config) int { if cfg.Context.AutoCompact.Enabled && cfg.Context.AutoCompact.InputTokenThreshold > 0 { diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 6ffa1114..1a345f5a 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -19,7 +19,6 @@ const ( defaultProviderRetryMax = 2 providerRetryBaseWait = 1 * time.Second providerRetryMaxWait = 5 * time.Second - defaultMaxLoops = 8 defaultToolParallelism = 4 noProgressStreakLimit = 3 diff --git a/internal/runtime/runtime_internal_helpers_test.go b/internal/runtime/runtime_internal_helpers_test.go index 71314a6f..97d779ad 100644 --- a/internal/runtime/runtime_internal_helpers_test.go +++ b/internal/runtime/runtime_internal_helpers_test.go @@ -7,7 +7,6 @@ import ( "testing" "time" - "neo-code/internal/config" "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" "neo-code/internal/runtime/approval" @@ -189,20 +188,6 @@ func TestAppendToolMessageAndSaveUnlocksStateBeforePersist(t *testing.T) { } } -func TestResolveMaxLoopsBranches(t *testing.T) { - t.Parallel() - - if got := resolveMaxLoops(config.Config{MaxLoops: 0}); got != defaultMaxLoops { - t.Fatalf("expected default max loops for zero, got %d", got) - } - if got := resolveMaxLoops(config.Config{MaxLoops: -3}); got != defaultMaxLoops { - t.Fatalf("expected default max loops for negative, got %d", got) - } - if got := resolveMaxLoops(config.Config{MaxLoops: 12}); got != 12 { - t.Fatalf("expected explicit max loops, got %d", got) - } -} - func TestEmitTokenUsageSkipsZeroUsage(t *testing.T) { t.Parallel() diff --git a/internal/runtime/runtime_progress_test.go b/internal/runtime/runtime_progress_test.go index f3fc6811..7e48e2c1 100644 --- a/internal/runtime/runtime_progress_test.go +++ b/internal/runtime/runtime_progress_test.go @@ -92,7 +92,6 @@ func TestProgressEvidenceResetsNoProgressStreak(t *testing.T) { cfg := config.Config{ Providers: []config.ProviderConfig{{Name: "test-progress", Driver: "test", BaseURL: "http://localhost", Model: "test", APIKeyEnv: "TEST_KEY"}}, SelectedProvider: "test-progress", - MaxLoops: 5, Workdir: t.TempDir(), } @@ -110,12 +109,19 @@ func TestProgressEvidenceResetsNoProgressStreak(t *testing.T) { }, } + var providerCalls int32 providerFactory := &scriptedProviderFactory{ provider: &scriptedProvider{ chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { - events <- providertypes.NewToolCallStartStreamEvent(0, "call_mixed", "tool_mixed") - events <- providertypes.NewToolCallDeltaStreamEvent(0, "call_mixed", "{}") - events <- providertypes.NewMessageDoneStreamEvent("tool_calls", nil) + call := int(atomic.AddInt32(&providerCalls, 1)) + if call <= 4 { + events <- providertypes.NewToolCallStartStreamEvent(0, "call_mixed", "tool_mixed") + events <- providertypes.NewToolCallDeltaStreamEvent(0, "call_mixed", "{}") + events <- providertypes.NewMessageDoneStreamEvent("tool_calls", nil) + return nil + } + events <- providertypes.NewTextDeltaStreamEvent("done") + events <- providertypes.NewMessageDoneStreamEvent("stop", nil) return nil }, }, @@ -134,10 +140,24 @@ func TestProgressEvidenceResetsNoProgressStreak(t *testing.T) { RunID: "run-progress-reset", Content: "trigger mixed progress loop", }) - if !errors.Is(err, ErrMaxLoopReached) { - t.Fatalf("expected ErrMaxLoopReached, got %v", err) + if err != nil { + t.Fatalf("expected run to finish successfully, got %v", err) + } + + if executeCalls != 4 { + t.Fatalf("expected 4 tool executions, got %d", executeCalls) } - if errors.Is(err, ErrNoProgressStreakLimit) { - t.Fatalf("expected not to hit no-progress streak limit, got %v", err) + if providerCalls != 5 { + t.Fatalf("expected 5 provider calls (4 tool turns + 1 done), got %d", providerCalls) + } + + events := collectRuntimeEvents(service.Events()) + for _, e := range events { + if e.Type == EventStopReasonDecided { + payload := e.Payload.(StopReasonDecidedPayload) + if payload.Reason != controlplane.StopReasonSuccess { + t.Fatalf("expected stop reason success, got %s", payload.Reason) + } + } } } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index a79ef3d7..72837909 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -1654,7 +1654,6 @@ func TestServiceRunErrorPaths(t *testing.T) { tests := []struct { name string input UserInput - maxLoops int provider *scriptedProvider factoryErr error registerTool *stubTool @@ -1675,28 +1674,43 @@ func TestServiceRunErrorPaths(t *testing.T) { }, }, { - name: "max loops reached after repeated tool cycles", - input: UserInput{RunID: "run-max-loops", Content: "loop"}, - maxLoops: 1, - provider: &scriptedProvider{ - streams: [][]providertypes.StreamEvent{ - { - providertypes.NewToolCallStartStreamEvent(0, "loop-call", "filesystem_edit"), - providertypes.NewToolCallDeltaStreamEvent(0, "loop-call", `{"path":"x"}`), - }, - }, - }, + name: "repeated tool cycles continue until assistant completion", + input: UserInput{RunID: "run-many-tool-cycles", Content: "loop"}, + provider: func() *scriptedProvider { + responses := make([]scriptedResponse, 0, 10) + for i := 0; i < 9; i++ { + responses = append(responses, scriptedResponse{ + Message: providertypes.Message{ + ToolCalls: []providertypes.ToolCall{ + { + ID: fmt.Sprintf("loop-call-%d", i), + Name: "filesystem_edit", + Arguments: `{"path":"x"}`, + }, + }, + }, + FinishReason: "tool_calls", + }) + } + responses = append(responses, scriptedResponse{ + Message: providertypes.Message{Content: "done after many cycles"}, + FinishReason: "stop", + }) + return &scriptedProvider{responses: responses} + }(), registerTool: &stubTool{name: "filesystem_edit", content: "loop tool output"}, - expectErr: "max loop reached", - expectEvents: []EventType{EventUserMessage, EventToolStart, EventToolChunk, EventToolResult, EventStopReasonDecided}, + expectEvents: []EventType{EventUserMessage, EventToolStart, EventToolChunk, EventToolResult, EventAgentDone}, assert: func(t *testing.T, store *memoryStore, scripted *scriptedProvider, tool *stubTool) { t.Helper() - if scripted.callCount != 1 { - t.Fatalf("expected one provider call before loop exit, got %d", scripted.callCount) + if scripted.callCount != 10 { + t.Fatalf("expected 10 provider calls without loop cap, got %d", scripted.callCount) } session := onlySession(t, store) - if len(session.Messages) != 3 { - t.Fatalf("expected user, assistant, tool messages before abort, got %d", len(session.Messages)) + if got := len(session.Messages); got != 20 { + t.Fatalf("expected 20 persisted messages after 9 tool cycles and final answer, got %d", got) + } + if session.Messages[len(session.Messages)-1].Content != "done after many cycles" { + t.Fatalf("expected final assistant reply to be persisted, got %+v", session.Messages[len(session.Messages)-1]) } }, }, @@ -1835,14 +1849,6 @@ func TestServiceRunErrorPaths(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { manager := newRuntimeConfigManager(t) - if tt.maxLoops > 0 { - if err := manager.Update(context.Background(), func(cfg *config.Config) error { - cfg.MaxLoops = tt.maxLoops - return nil - }); err != nil { - t.Fatalf("update max loops: %v", err) - } - } store := newMemoryStore() if tt.seedSession != nil { @@ -1884,275 +1890,6 @@ func TestServiceRunErrorPaths(t *testing.T) { } } -func TestServiceRunMaxLoopsSavesLoopLimitCheckpoint(t *testing.T) { - t.Parallel() - - manager := newRuntimeConfigManager(t) - if err := manager.Update(context.Background(), func(cfg *config.Config) error { - cfg.MaxLoops = 1 - return nil - }); err != nil { - t.Fatalf("update config: %v", err) - } - store := newMemoryStore() - session := agentsession.New("loop-limit-success") - session.ID = "session-loop-limit-success" - session.TokenInputTotal = 33 - session.TokenOutputTotal = 7 - session.Messages = []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older request"}, - } - store.sessions[session.ID] = cloneSession(session) - - registry := tools.NewRegistry() - tool := &stubTool{name: "filesystem_edit", content: "loop tool output"} - registry.Register(tool) - - scripted := &scriptedProvider{ - responses: []scriptedResponse{ - { - Message: providertypes.Message{ - ToolCalls: []providertypes.ToolCall{ - {ID: "loop-call", Name: "filesystem_edit", Arguments: `{"path":"x"}`}, - }, - }, - FinishReason: "tool_calls", - }, - }, - } - - service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) - compactRunner := &stubCompactRunner{ - result: contextcompact.Result{ - Messages: []providertypes.Message{ - {Role: providertypes.RoleAssistant, Content: "[compact_summary]\ndone:\n- checkpoint saved\n\nin_progress:\n- continue"}, - {Role: providertypes.RoleUser, Content: "continue from saved checkpoint"}, - }, - TaskState: agentsession.TaskState{ - Goal: "Preserve continuation checkpoint", - Progress: []string{"Saved loop-limit compact checkpoint"}, - NextStep: "Resume analysis from compacted context", - OpenItems: []string{"Continue source inspection"}, - Decisions: []string{"Use loop_limit compact before max loop exit"}, - Blockers: []string{}, - KeyArtifacts: []string{ - "checkpoint-summary", - }, - UserConstraints: []string{"Prefer durable checkpoint over raw message tail"}, - }, - Applied: true, - Metrics: contextcompact.Metrics{ - BeforeChars: 120, - AfterChars: 48, - SavedRatio: 0.6, - TriggerMode: string(contextcompact.ModeLoopLimit), - }, - TranscriptID: "transcript_loop_limit", - TranscriptPath: "/tmp/loop_limit.jsonl", - }, - } - service.compactRunner = compactRunner - - err := service.Run(context.Background(), UserInput{ - SessionID: session.ID, - RunID: "run-loop-limit-success", - Content: "continue", - }) - if err == nil || !strings.Contains(err.Error(), "max loop reached after saving continuation checkpoint") { - t.Fatalf("expected loop-limit checkpoint error, got %v", err) - } - - if len(compactRunner.calls) != 1 { - t.Fatalf("expected loop-limit compact to run once, got %d", len(compactRunner.calls)) - } - if compactRunner.calls[0].Mode != contextcompact.ModeLoopLimit { - t.Fatalf("expected compact mode %q, got %q", contextcompact.ModeLoopLimit, compactRunner.calls[0].Mode) - } - if compactRunner.calls[0].SessionInputTokens != 33 { - t.Fatalf("expected pre-checkpoint input tokens to be preserved, got %d", compactRunner.calls[0].SessionInputTokens) - } - - saved, err := store.Load(context.Background(), session.ID) - if err != nil { - t.Fatalf("load compacted session: %v", err) - } - if len(saved.Messages) != 2 || !strings.Contains(saved.Messages[0].Content, "checkpoint saved") { - t.Fatalf("expected compacted checkpoint messages, got %+v", saved.Messages) - } - if saved.TaskState.Goal != "Preserve continuation checkpoint" { - t.Fatalf("expected persisted task state, got %+v", saved.TaskState) - } - if saved.TokenInputTotal != 0 || saved.TokenOutputTotal != 0 { - t.Fatalf("expected token totals reset after loop-limit checkpoint, got input=%d output=%d", saved.TokenInputTotal, saved.TokenOutputTotal) - } - - events := collectRuntimeEvents(service.Events()) - assertEventSequence(t, events, []EventType{ - EventUserMessage, - EventToolStart, - EventToolChunk, - EventToolResult, - EventCompactStart, - EventCompactApplied, - EventError, - }) - assertNoEventType(t, events, EventCompactError) - - compactStartIndex := eventIndex(events, EventCompactStart) - compactDoneIndex := eventIndex(events, EventCompactApplied) - errorIndex := eventIndex(events, EventError) - if compactStartIndex == -1 || compactDoneIndex == -1 || errorIndex == -1 { - t.Fatalf("expected loop-limit events in %+v", events) - } - if !(compactStartIndex < compactDoneIndex && compactDoneIndex < errorIndex) { - t.Fatalf("expected compact_start -> compact_done -> error ordering, got %+v", events) - } - - foundLoopLimitDone := false - foundLoopLimitError := false - for _, event := range events { - switch event.Type { - case EventCompactApplied: - payload, ok := event.Payload.(CompactResult) - if !ok { - t.Fatalf("expected CompactResult payload, got %T", event.Payload) - } - if payload.TriggerMode == string(contextcompact.ModeLoopLimit) { - foundLoopLimitDone = true - } - case EventError: - message, ok := event.Payload.(string) - if !ok { - t.Fatalf("expected string error payload, got %T", event.Payload) - } - if strings.Contains(message, "after saving continuation checkpoint") { - foundLoopLimitError = true - } - } - } - if !foundLoopLimitDone { - t.Fatalf("expected loop_limit compact_done payload in %+v", events) - } - if !foundLoopLimitError { - t.Fatalf("expected saved-checkpoint error payload in %+v", events) - } -} - -func TestServiceRunMaxLoopsLoopLimitCompactFailureFallsBackToOriginalExit(t *testing.T) { - t.Parallel() - - manager := newRuntimeConfigManager(t) - if err := manager.Update(context.Background(), func(cfg *config.Config) error { - cfg.MaxLoops = 1 - return nil - }); err != nil { - t.Fatalf("update config: %v", err) - } - store := newMemoryStore() - session := agentsession.New("loop-limit-failure") - session.ID = "session-loop-limit-failure" - session.TokenInputTotal = 12 - session.TokenOutputTotal = 4 - session.Messages = []providertypes.Message{ - {Role: providertypes.RoleUser, Content: "older request"}, - } - store.sessions[session.ID] = cloneSession(session) - - registry := tools.NewRegistry() - registry.Register(&stubTool{name: "filesystem_edit", content: "loop tool output"}) - - scripted := &scriptedProvider{ - responses: []scriptedResponse{ - { - Message: providertypes.Message{ - ToolCalls: []providertypes.ToolCall{ - {ID: "loop-call", Name: "filesystem_edit", Arguments: `{"path":"x"}`}, - }, - }, - FinishReason: "tool_calls", - }, - }, - } - - service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) - compactRunner := &stubCompactRunner{err: errors.New("loop limit compact failed")} - service.compactRunner = compactRunner - - err := service.Run(context.Background(), UserInput{ - SessionID: session.ID, - RunID: "run-loop-limit-failure", - Content: "continue", - }) - if err == nil || !strings.Contains(err.Error(), "max loop reached") || strings.Contains(err.Error(), "after saving continuation checkpoint") { - t.Fatalf("expected original max loop error, got %v", err) - } - - if len(compactRunner.calls) != 1 { - t.Fatalf("expected loop-limit compact to be attempted once, got %d", len(compactRunner.calls)) - } - if compactRunner.calls[0].Mode != contextcompact.ModeLoopLimit { - t.Fatalf("expected compact mode %q, got %q", contextcompact.ModeLoopLimit, compactRunner.calls[0].Mode) - } - - saved, err := store.Load(context.Background(), session.ID) - if err != nil { - t.Fatalf("load session after failed checkpoint: %v", err) - } - if len(saved.Messages) != 4 { - t.Fatalf("expected original messages to remain after compact failure, got %+v", saved.Messages) - } - if saved.TokenInputTotal != 12 || saved.TokenOutputTotal != 4 { - t.Fatalf("expected token totals to remain unchanged, got input=%d output=%d", saved.TokenInputTotal, saved.TokenOutputTotal) - } - - events := collectRuntimeEvents(service.Events()) - assertEventSequence(t, events, []EventType{ - EventUserMessage, - EventToolStart, - EventToolChunk, - EventToolResult, - EventCompactStart, - EventCompactError, - EventError, - }) - assertNoEventType(t, events, EventCompactApplied) - - compactStartIndex := eventIndex(events, EventCompactStart) - compactErrorIndex := eventIndex(events, EventCompactError) - errorIndex := eventIndex(events, EventError) - if compactStartIndex == -1 || compactErrorIndex == -1 || errorIndex == -1 { - t.Fatalf("expected loop-limit failure events in %+v", events) - } - if !(compactStartIndex < compactErrorIndex && compactErrorIndex < errorIndex) { - t.Fatalf("expected compact_start -> compact_error -> error ordering, got %+v", events) - } - - foundLoopLimitCompactError := false - for _, event := range events { - switch event.Type { - case EventCompactError: - payload, ok := event.Payload.(CompactErrorPayload) - if !ok { - t.Fatalf("expected CompactErrorPayload, got %T", event.Payload) - } - if payload.TriggerMode == string(contextcompact.ModeLoopLimit) && strings.Contains(payload.Message, "loop limit compact failed") { - foundLoopLimitCompactError = true - } - case EventError: - message, ok := event.Payload.(string) - if !ok { - t.Fatalf("expected string error payload, got %T", event.Payload) - } - if strings.Contains(message, "after saving continuation checkpoint") { - t.Fatalf("did not expect saved-checkpoint message after compact failure: %+v", events) - } - } - } - if !foundLoopLimitCompactError { - t.Fatalf("expected loop_limit compact_error payload in %+v", events) - } -} - func TestServiceCancelActiveRun(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() @@ -3113,7 +2850,6 @@ func newRuntimeConfigManagerWithProviderEnvs(t *testing.T, providerEnvs map[stri } if err := manager.Update(context.Background(), func(cfg *config.Config) error { cfg.ToolTimeoutSec = 1 - cfg.MaxLoops = 4 cfg.Workdir = defaultWorkdir return nil }); err != nil { @@ -4156,16 +3892,10 @@ func TestServiceRunReactivelyCompactsOnContextTooLong(t *testing.T) { } } -func TestServiceRunReactivelyCompactsWithinSingleLoopBudget(t *testing.T) { +func TestServiceRunReactiveCompactRetriesWithinSameRun(t *testing.T) { t.Parallel() manager := newRuntimeConfigManager(t) - if err := manager.Update(context.Background(), func(cfg *config.Config) error { - cfg.MaxLoops = 1 - return nil - }); err != nil { - t.Fatalf("update config: %v", err) - } store := newMemoryStore() session := agentsession.New("reactive-single-loop") @@ -4225,11 +3955,11 @@ func TestServiceRunReactivelyCompactsWithinSingleLoopBudget(t *testing.T) { RunID: "run-reactive-single-loop", Content: "continue", }); err != nil { - t.Fatalf("Run() with MaxLoops=1 should recover, got %v", err) + t.Fatalf("Run() should recover after reactive compact, got %v", err) } if scripted.callCount != 2 { - t.Fatalf("expected provider to be called twice within one loop budget, got %d", scripted.callCount) + t.Fatalf("expected provider to be called twice within the same run, got %d", scripted.callCount) } events := collectRuntimeEvents(service.Events()) From 0c3a8c01da68a1b68d2e57ecb7ae9daeac61fb8c Mon Sep 17 00:00:00 2001 From: xgopilot Date: Tue, 14 Apr 2026 15:42:59 +0000 Subject: [PATCH 9/9] test(runtime,tui): fix compile regression and cover new branches Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/runtime/controlplane/decider_test.go | 20 ++ .../runtime/runtime_branch_coverage_test.go | 279 ++++++++++++++++++ internal/runtime/runtime_progress_test.go | 1 - .../core/app/update_runtime_events_test.go | 133 +++++++++ 4 files changed, 432 insertions(+), 1 deletion(-) create mode 100644 internal/runtime/runtime_branch_coverage_test.go create mode 100644 internal/tui/core/app/update_runtime_events_test.go diff --git a/internal/runtime/controlplane/decider_test.go b/internal/runtime/controlplane/decider_test.go index 942eb565..1f41b8e4 100644 --- a/internal/runtime/controlplane/decider_test.go +++ b/internal/runtime/controlplane/decider_test.go @@ -66,3 +66,23 @@ func TestDecideStopReasonPriority(t *testing.T) { }) } } + +func TestDecideStopReasonDetails(t *testing.T) { + t.Parallel() + + reason, detail := DecideStopReason(StopInput{MaxLoopsReached: true}) + if reason != StopReasonMaxLoops { + t.Fatalf("reason = %q, want %q", reason, StopReasonMaxLoops) + } + if detail != "runtime: max loop reached" { + t.Fatalf("detail = %q, want default max-loop detail", detail) + } + + reason, detail = DecideStopReason(StopInput{}) + if reason != StopReasonError { + t.Fatalf("reason = %q, want %q", reason, StopReasonError) + } + if detail != "runtime: stop reason undetermined" { + t.Fatalf("detail = %q, want undetermined detail", detail) + } +} diff --git a/internal/runtime/runtime_branch_coverage_test.go b/internal/runtime/runtime_branch_coverage_test.go new file mode 100644 index 00000000..318adbaf --- /dev/null +++ b/internal/runtime/runtime_branch_coverage_test.go @@ -0,0 +1,279 @@ +package runtime + +import ( + "context" + "errors" + "sync" + "testing" + + providertypes "neo-code/internal/provider/types" + "neo-code/internal/runtime/controlplane" + agentsession "neo-code/internal/session" +) + +func TestExecuteAssistantToolCallsReturnsNilForEmptyCalls(t *testing.T) { + t.Parallel() + + service := &Service{} + state := &runState{} + err := service.executeAssistantToolCalls(context.Background(), state, turnSnapshot{}, providertypes.Message{}) + if err != nil { + t.Fatalf("executeAssistantToolCalls() error = %v", err) + } +} + +func TestExecuteOneToolCallStopsWhenContextCheckReturnsTrue(t *testing.T) { + t.Parallel() + + service := &Service{} + state := newRunState("run-stop", newRuntimeSession("session-stop")) + called := false + + service.executeOneToolCall( + context.Background(), + &state, + turnSnapshot{}, + providertypes.ToolCall{ID: "call-1", Name: "noop"}, + &sync.Mutex{}, + func() bool { return true }, + func(error) { called = true }, + ) + if called { + t.Fatalf("rememberError should not be called when execution is short-circuited") + } +} + +func TestResolveToolParallelismBounds(t *testing.T) { + t.Parallel() + + if got := resolveToolParallelism(0); got != 1 { + t.Fatalf("resolveToolParallelism(0) = %d, want 1", got) + } + if got := resolveToolParallelism(2); got != 2 { + t.Fatalf("resolveToolParallelism(2) = %d, want 2", got) + } + if got := resolveToolParallelism(defaultToolParallelism + 3); got != defaultToolParallelism { + t.Fatalf("resolveToolParallelism(overflow) = %d, want %d", got, defaultToolParallelism) + } +} + +func TestRememberFirstErrorBranches(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + var firstErr error + errA := errors.New("err-a") + errB := errors.New("err-b") + + if rememberFirstError(&mu, &firstErr, nil) { + t.Fatalf("rememberFirstError(nil) should return false") + } + if firstErr != nil { + t.Fatalf("firstErr should stay nil for nil input") + } + + if !rememberFirstError(&mu, &firstErr, errA) { + t.Fatalf("first non-nil error should be recorded") + } + if !errors.Is(firstErr, errA) { + t.Fatalf("expected firstErr to be errA, got %v", firstErr) + } + + if rememberFirstError(&mu, &firstErr, errB) { + t.Fatalf("second error should not replace firstErr") + } + if !errors.Is(firstErr, errA) { + t.Fatalf("expected firstErr to remain errA, got %v", firstErr) + } +} + +func TestTransitionRunPhaseNoopBranches(t *testing.T) { + t.Parallel() + + service := &Service{events: make(chan RuntimeEvent, 4)} + service.transitionRunPhase(context.Background(), nil, controlplane.PhasePlan) + + state := newRunState("run-phase", newRuntimeSession("session-phase")) + state.phase = controlplane.PhasePlan + service.transitionRunPhase(context.Background(), &state, controlplane.PhasePlan) + + events := collectRuntimeEvents(service.Events()) + if len(events) != 0 { + t.Fatalf("expected no phase event for noop transition, got %+v", events) + } +} + +func TestEmitRunTerminationMaxLoopsBranch(t *testing.T) { + t.Parallel() + + service := &Service{events: make(chan RuntimeEvent, 4)} + state := newRunState("run-max-loops", newRuntimeSession("session-max-loops")) + service.emitRunTermination( + context.Background(), + UserInput{RunID: "run-max-loops", SessionID: state.session.ID}, + &state, + ErrMaxLoopReached, + ) + + events := collectRuntimeEvents(service.Events()) + if len(events) != 1 || events[0].Type != EventStopReasonDecided { + t.Fatalf("expected one stop_reason_decided event, got %+v", events) + } + payload, ok := events[0].Payload.(StopReasonDecidedPayload) + if !ok { + t.Fatalf("expected StopReasonDecidedPayload, got %#v", events[0].Payload) + } + if payload.Reason != controlplane.StopReasonMaxLoops { + t.Fatalf("reason = %q, want %q", payload.Reason, controlplane.StopReasonMaxLoops) + } +} + +func TestCloneMessagesReturnsNilForEmptyInput(t *testing.T) { + t.Parallel() + + if cloned := cloneMessages(nil); cloned != nil { + t.Fatalf("cloneMessages(nil) should return nil, got %#v", cloned) + } +} + +func TestEmitRunScopedNilStateFallsBackToBaseEnvelope(t *testing.T) { + t.Parallel() + + service := &Service{events: make(chan RuntimeEvent, 4)} + if err := service.emitRunScoped(context.Background(), EventAgentChunk, nil, "chunk"); err != nil { + t.Fatalf("emitRunScoped() error = %v", err) + } + + events := collectRuntimeEvents(service.Events()) + if len(events) != 1 { + t.Fatalf("expected one event, got %d", len(events)) + } + event := events[0] + if event.RunID != "" || event.SessionID != "" || event.Turn != turnUnspecified { + t.Fatalf("unexpected fallback envelope: %+v", event) + } +} + +func TestEmitWithEnvelopeBackfillsVersionAndTimestamp(t *testing.T) { + t.Parallel() + + service := &Service{events: make(chan RuntimeEvent, 4)} + if err := service.emitWithEnvelope(context.Background(), RuntimeEvent{ + Type: EventAgentChunk, + Payload: "chunk", + }); err != nil { + t.Fatalf("emitWithEnvelope() error = %v", err) + } + + events := collectRuntimeEvents(service.Events()) + if len(events) != 1 { + t.Fatalf("expected one event, got %d", len(events)) + } + event := events[0] + if event.PayloadVersion != controlplane.PayloadVersion { + t.Fatalf("payload_version = %d, want %d", event.PayloadVersion, controlplane.PayloadVersion) + } + if event.Timestamp.IsZero() { + t.Fatalf("expected timestamp to be set") + } +} + +func TestFinishRunPromotesLatestRemainingToken(t *testing.T) { + t.Parallel() + + service := &Service{activeRunCancels: make(map[uint64]context.CancelFunc)} + token1 := service.startRun(func() {}) + token2 := service.startRun(func() {}) + + service.finishRun(token2) + if service.activeRunToken != token1 { + t.Fatalf("activeRunToken = %d, want %d", service.activeRunToken, token1) + } + if _, exists := service.activeRunCancels[token1]; !exists { + t.Fatalf("expected token1 cancel handle to remain") + } +} + +func TestAcquireSessionLockReleaseGuards(t *testing.T) { + t.Parallel() + + service := &Service{} + _, releaseMissing := service.acquireSessionLock("session-missing") + + service.sessionMu.Lock() + delete(service.sessionLocks, "session-missing") + service.sessionMu.Unlock() + releaseMissing() + releaseMissing() + + _, releaseMismatch := service.acquireSessionLock("session-mismatch") + service.sessionMu.Lock() + service.sessionLocks["session-mismatch"] = &sessionLockEntry{} + service.sessionMu.Unlock() + releaseMismatch() +} + +func TestPermissionAskLockKeyFallbacks(t *testing.T) { + t.Parallel() + + if got := permissionAskLockKey("run-1", "session-1"); got != "run:run-1" { + t.Fatalf("expected run scope key, got %q", got) + } + if got := permissionAskLockKey("", "session-1"); got != "session:session-1" { + t.Fatalf("expected session scope key, got %q", got) + } + if got := permissionAskLockKey(" ", " "); got != "global" { + t.Fatalf("expected global key fallback, got %q", got) + } +} + +func TestAcquirePermissionAskLockReleaseGuards(t *testing.T) { + t.Parallel() + + service := &Service{} + _, releaseMissing := service.acquirePermissionAskLock("run-missing", "") + service.permissionAskMapMu.Lock() + delete(service.permissionAskLocks, "run:run-missing") + service.permissionAskMapMu.Unlock() + releaseMissing() + + _, releaseMismatch := service.acquirePermissionAskLock("run-mismatch", "") + service.permissionAskMapMu.Lock() + service.permissionAskLocks["run:run-mismatch"] = &permissionAskLockEntry{} + service.permissionAskMapMu.Unlock() + releaseMismatch() +} + +func TestClonePersistenceHelpersBranches(t *testing.T) { + t.Parallel() + + if cloned := cloneMessagesForPersistence(nil); cloned != nil { + t.Fatalf("cloneMessagesForPersistence(nil) should return nil") + } + message := providertypes.Message{Role: providertypes.RoleTool} + clonedMessages := cloneMessagesForPersistence([]providertypes.Message{message}) + if len(clonedMessages) != 1 { + t.Fatalf("expected one cloned message, got %d", len(clonedMessages)) + } + if clonedMessages[0].ToolCalls != nil { + t.Fatalf("expected nil tool calls for empty source") + } + if clonedMessages[0].ToolMetadata != nil { + t.Fatalf("expected nil tool metadata for empty source") + } + + if cloned := cloneTodosForPersistence(nil); cloned != nil { + t.Fatalf("cloneTodosForPersistence(nil) should return nil") + } + todos := []agentsession.TodoItem{ + {ID: "t1", Content: "task", Dependencies: []string{"dep-1"}}, + } + clonedTodos := cloneTodosForPersistence(todos) + if len(clonedTodos) != 1 { + t.Fatalf("expected one cloned todo, got %d", len(clonedTodos)) + } + todos[0].Dependencies[0] = "changed" + if clonedTodos[0].Dependencies[0] != "dep-1" { + t.Fatalf("expected todo dependencies to be deep-copied") + } +} diff --git a/internal/runtime/runtime_progress_test.go b/internal/runtime/runtime_progress_test.go index 7e48e2c1..f83f73f2 100644 --- a/internal/runtime/runtime_progress_test.go +++ b/internal/runtime/runtime_progress_test.go @@ -18,7 +18,6 @@ func TestProgressStreakStopsRun(t *testing.T) { cfg := config.Config{ Providers: []config.ProviderConfig{{Name: "test-progress", Driver: "test", BaseURL: "http://localhost", Model: "test", APIKeyEnv: "TEST_KEY"}}, SelectedProvider: "test-progress", - MaxLoops: 10, // High enough to not trigger max loops Workdir: t.TempDir(), } diff --git a/internal/tui/core/app/update_runtime_events_test.go b/internal/tui/core/app/update_runtime_events_test.go new file mode 100644 index 00000000..8e571af0 --- /dev/null +++ b/internal/tui/core/app/update_runtime_events_test.go @@ -0,0 +1,133 @@ +package tui + +import ( + "testing" + + agentruntime "neo-code/internal/runtime" + "neo-code/internal/runtime/controlplane" +) + +func TestRuntimeEventPhaseChangedHandlerBranches(t *testing.T) { + t.Parallel() + + app, _ := newTestApp(t) + if handled := runtimeEventPhaseChangedHandler(&app, agentruntime.RuntimeEvent{Payload: "invalid"}); handled { + t.Fatalf("expected invalid payload to return false") + } + + cases := []struct { + to string + wantValue float64 + wantLabel string + }{ + {to: " plan ", wantValue: 0.3, wantLabel: "Planning"}, + {to: "execute", wantValue: 0.6, wantLabel: "Running tools"}, + {to: "VERIFY", wantValue: 0.82, wantLabel: "Verifying"}, + } + for _, tc := range cases { + app.clearRunProgress() + handled := runtimeEventPhaseChangedHandler(&app, agentruntime.RuntimeEvent{ + Payload: agentruntime.PhaseChangedPayload{To: tc.to}, + }) + if handled { + t.Fatalf("expected phase handler to return false") + } + if !app.runProgressKnown || app.runProgressValue != tc.wantValue || app.runProgressLabel != tc.wantLabel { + t.Fatalf("unexpected progress for %q: known=%v value=%v label=%q", tc.to, app.runProgressKnown, app.runProgressValue, app.runProgressLabel) + } + } +} + +func TestRuntimeEventStopReasonDecidedHandlerBranches(t *testing.T) { + t.Parallel() + + app, _ := newTestApp(t) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-1"}, + } + app.state.IsAgentRunning = true + app.state.StreamingReply = true + app.state.CurrentTool = "bash" + app.state.ActiveRunID = "run-1" + app.state.ExecutionError = "should-clear" + app.setRunProgress(0.8, "running") + + if handled := runtimeEventStopReasonDecidedHandler(&app, agentruntime.RuntimeEvent{Payload: 123}); handled { + t.Fatalf("expected invalid payload to return false") + } + + handled := runtimeEventStopReasonDecidedHandler(&app, agentruntime.RuntimeEvent{ + Payload: agentruntime.StopReasonDecidedPayload{Reason: controlplane.StopReason(" success ")}, + }) + if handled { + t.Fatalf("expected handler to return false") + } + if app.state.IsAgentRunning || app.state.StreamingReply || app.state.CurrentTool != "" || app.state.ActiveRunID != "" { + t.Fatalf("expected run flags to be reset") + } + if app.pendingPermission != nil { + t.Fatalf("expected pending permission to be cleared") + } + if app.runProgressKnown { + t.Fatalf("expected run progress to be cleared") + } + if app.state.StatusText != statusReady { + t.Fatalf("expected success status %q, got %q", statusReady, app.state.StatusText) + } + + app.state.ExecutionError = "" + app.state.StatusText = "not-ready" + runtimeEventStopReasonDecidedHandler(&app, agentruntime.RuntimeEvent{ + Payload: agentruntime.StopReasonDecidedPayload{Reason: controlplane.StopReason("success")}, + }) + if app.state.StatusText != statusReady { + t.Fatalf("expected success with empty execution error to set ready status") + } + + app.state.ExecutionError = "boom" + app.state.StatusText = "" + runtimeEventStopReasonDecidedHandler(&app, agentruntime.RuntimeEvent{ + Payload: agentruntime.StopReasonDecidedPayload{Reason: controlplane.StopReason("success")}, + }) + if app.state.StatusText == statusReady { + t.Fatalf("expected success branch to keep status unchanged when execution error exists") + } + + runtimeEventStopReasonDecidedHandler(&app, agentruntime.RuntimeEvent{ + Payload: agentruntime.StopReasonDecidedPayload{Reason: controlplane.StopReason("canceled")}, + }) + if app.state.ExecutionError != "" || app.state.StatusText != statusCanceled { + t.Fatalf("expected canceled state to clear error and set canceled status") + } + + runtimeEventStopReasonDecidedHandler(&app, agentruntime.RuntimeEvent{ + Payload: agentruntime.StopReasonDecidedPayload{Reason: controlplane.StopReason("error"), Detail: " "}, + }) + if app.state.StatusText != "runtime stopped" || app.state.ExecutionError != "runtime stopped" { + t.Fatalf("expected default stop detail, got status=%q err=%q", app.state.StatusText, app.state.ExecutionError) + } + + runtimeEventStopReasonDecidedHandler(&app, agentruntime.RuntimeEvent{ + Payload: agentruntime.StopReasonDecidedPayload{Reason: controlplane.StopReason("error"), Detail: "explicit failure"}, + }) + if app.state.StatusText != "explicit failure" || app.state.ExecutionError != "explicit failure" { + t.Fatalf("expected explicit stop detail to be surfaced") + } +} + +func TestRuntimeEventHandlerRegistryContainsRenamedEvents(t *testing.T) { + t.Parallel() + + if _, ok := runtimeEventHandlerRegistry[agentruntime.EventPhaseChanged]; !ok { + t.Fatalf("expected phase_changed handler to be registered") + } + if _, ok := runtimeEventHandlerRegistry[agentruntime.EventStopReasonDecided]; !ok { + t.Fatalf("expected stop_reason_decided handler to be registered") + } + if _, ok := runtimeEventHandlerRegistry[agentruntime.EventPermissionRequested]; !ok { + t.Fatalf("expected permission_requested handler to be registered") + } + if _, ok := runtimeEventHandlerRegistry[agentruntime.EventCompactApplied]; !ok { + t.Fatalf("expected compact_applied handler to be registered") + } +}