Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions internal/runtime/controlplane/completion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package controlplane

// CompletionBlockedReason 表示 completion gate 阻塞完成的原因。
type CompletionBlockedReason string

const (
// CompletionBlockedReasonNone 表示当前不存在阻塞原因。
CompletionBlockedReasonNone CompletionBlockedReason = ""
// CompletionBlockedReasonPendingTodo 表示仍存在未完成
CompletionBlockedReasonPendingTodo CompletionBlockedReason = "pending_todo"
// CompletionBlockedReasonUnverifiedWrite 表示仍存在未验证写入。
CompletionBlockedReasonUnverifiedWrite CompletionBlockedReason = "unverified_write"
// CompletionBlockedReasonPostExecuteClosureRequired 表示刚完成执行后仍需闭环。
CompletionBlockedReasonPostExecuteClosureRequired CompletionBlockedReason = "post_execute_closure_required"
)

// CompletionState 描述 completion gate 所需的运行事实。
type CompletionState struct {
HasPendingAgentTodos bool `json:"has_pending_agent_todos"`
HasUnverifiedWrites bool `json:"has_unverified_writes"`
CompletionBlockedReason CompletionBlockedReason `json:"completion_blocked_reason,omitempty"`
}

// EvaluateCompletion 依据当前事实计算是否允许本轮 completed。
func EvaluateCompletion(state CompletionState, assistantHasToolCalls bool) (CompletionState, bool) {
state.CompletionBlockedReason = CompletionBlockedReasonNone

if assistantHasToolCalls {
state.CompletionBlockedReason = CompletionBlockedReasonPostExecuteClosureRequired
return state, false
}
if state.HasPendingAgentTodos {
state.CompletionBlockedReason = CompletionBlockedReasonPendingTodo
return state, false
}
if state.HasUnverifiedWrites {
state.CompletionBlockedReason = CompletionBlockedReasonUnverifiedWrite
return state, false
}
return state, true
}
55 changes: 55 additions & 0 deletions internal/runtime/controlplane/completion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package controlplane

import "testing"

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

state, completed := EvaluateCompletion(CompletionState{
HasPendingAgentTodos: true,
}, false)
if completed {
t.Fatalf("expected completion to be blocked")
}
if state.CompletionBlockedReason != CompletionBlockedReasonPendingTodo {
t.Fatalf("blocked reason = %q, want %q", state.CompletionBlockedReason, CompletionBlockedReasonPendingTodo)
}
}

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

state, completed := EvaluateCompletion(CompletionState{
HasUnverifiedWrites: true,
}, false)
if completed {
t.Fatalf("expected completion to be blocked")
}
if state.CompletionBlockedReason != CompletionBlockedReasonUnverifiedWrite {
t.Fatalf("blocked reason = %q, want %q", state.CompletionBlockedReason, CompletionBlockedReasonUnverifiedWrite)
}
}

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

state, completed := EvaluateCompletion(CompletionState{}, true)
if completed {
t.Fatalf("expected completion to be blocked after tool call turn")
}
if state.CompletionBlockedReason != CompletionBlockedReasonPostExecuteClosureRequired {
t.Fatalf("blocked reason = %q, want %q", state.CompletionBlockedReason, CompletionBlockedReasonPostExecuteClosureRequired)
}
}

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

state, completed := EvaluateCompletion(CompletionState{}, false)
if !completed {
t.Fatalf("expected completion to succeed")
}
if state.CompletionBlockedReason != CompletionBlockedReasonNone {
t.Fatalf("blocked reason = %q, want empty", state.CompletionBlockedReason)
}
}
28 changes: 14 additions & 14 deletions internal/runtime/controlplane/decider.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,26 @@ import (
"strings"
)

// StopInput 汇总停止决议所需的信号(可多信号并存,由 DecideStopReason 按优先级表决)
// StopInput 汇总最终 stop 决议所需的信号
type StopInput struct {
ContextCanceled bool
RunError error
Success bool
UserInterrupted bool
FatalError error
Completed bool
}

// DecideStopReason 按固定优先级返回唯一 StopReason:取消 > 错误 > 成功
// DecideStopReason 按固定优先级返回唯一的最终 stop 原因
func DecideStopReason(in StopInput) (StopReason, string) {
if in.ContextCanceled {
return StopReasonCanceled, ""
if in.UserInterrupted {
return StopReasonUserInterrupt, ""
}
if in.RunError != nil {
if errors.Is(in.RunError, context.Canceled) {
return StopReasonCanceled, ""
if in.FatalError != nil {
if errors.Is(in.FatalError, context.Canceled) {
return StopReasonUserInterrupt, ""
}
return StopReasonError, strings.TrimSpace(in.RunError.Error())
return StopReasonFatalError, strings.TrimSpace(in.FatalError.Error())
}
if in.Success {
return StopReasonSuccess, ""
if in.Completed {
return StopReasonCompleted, ""
}
return StopReasonError, "runtime: stop reason undetermined"
return StopReasonFatalError, "runtime: stop reason undetermined"
}
42 changes: 22 additions & 20 deletions internal/runtime/controlplane/decider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,48 +11,50 @@ func TestDecideStopReasonPriority(t *testing.T) {

errSample := errors.New("boom")
cases := []struct {
name string
in StopInput
reason StopReason
name string
in StopInput
wantReason StopReason
}{
{
name: "canceled_wins_over_error",
name: "user_interrupt_wins_over_fatal",
in: StopInput{
ContextCanceled: true,
RunError: errSample,
UserInterrupted: true,
FatalError: errSample,
},
reason: StopReasonCanceled,
wantReason: StopReasonUserInterrupt,
},
{
name: "error",
name: "fatal_error_wins_over_completed",
in: StopInput{
RunError: errSample,
FatalError: errSample,
Completed: true,
},
reason: StopReasonError,
wantReason: StopReasonFatalError,
},
{
name: "success",
name: "completed",
in: StopInput{
Success: true,
Completed: true,
},
reason: StopReasonSuccess,
wantReason: StopReasonCompleted,
},
{
name: "context_canceled_on_error_field",
name: "context_canceled_maps_to_user_interrupt",
in: StopInput{
RunError: context.Canceled,
FatalError: context.Canceled,
},
reason: StopReasonCanceled,
wantReason: StopReasonUserInterrupt,
},
}

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)
if got != tc.wantReason {
t.Fatalf("DecideStopReason() = %q, want %q", got, tc.wantReason)
}
})
}
Expand All @@ -62,8 +64,8 @@ func TestDecideStopReasonDetails(t *testing.T) {
t.Parallel()

reason, detail := DecideStopReason(StopInput{})
if reason != StopReasonError {
t.Fatalf("reason = %q, want %q", reason, StopReasonError)
if reason != StopReasonFatalError {
t.Fatalf("reason = %q, want %q", reason, StopReasonFatalError)
}
if detail != "runtime: stop reason undetermined" {
t.Fatalf("detail = %q, want undetermined detail", detail)
Expand Down
2 changes: 1 addition & 1 deletion internal/runtime/controlplane/envelope.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package controlplane

// PayloadVersion 为 runtime 事件 envelope 的当前协议版本号。
const PayloadVersion = 1
const PayloadVersion = 2
80 changes: 70 additions & 10 deletions internal/runtime/controlplane/phase.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,75 @@
package controlplane

// Phase 表示单轮 ReAct 内的显式阶段(plan -> execute -> dispatch -> verify)。
type Phase string
import "fmt"

// RunState 表示单次 Run 生命周期中的显式运行态,统一承载主链 phase 与外围治理态。
type RunState string

const (
// PhasePlan 规划阶段:构建上下文、调用 provider 直至得到 assistant 消息(含工具调用决策)。
PhasePlan Phase = "plan"
// PhaseExecute 执行阶段:执行本批次全部工具调用。
PhaseExecute Phase = "execute"
// PhaseDispatch 调度阶段:执行 Todo 驱动的子代理任务派发。
PhaseDispatch Phase = "dispatch"
// PhaseVerify 验证阶段:工具结果已回灌,等待下一轮 provider 校验或收尾。
PhaseVerify Phase = "verify"
// RunStatePlan 表示规划阶段:构建上下文并驱动 provider 产出 assistant 决策。
RunStatePlan RunState = "plan"
// RunStateExecute 表示执行阶段:执行本轮 assistant 产生的全部工具调用。
RunStateExecute RunState = "execute"
// RunStateVerify 表示验证阶段:工具结果已回灌,等待下一轮模型收尾或继续推进。
RunStateVerify RunState = "verify"
// RunStateCompacting 表示当前正在执行 compact 或 reactive compact。
RunStateCompacting RunState = "compacting"
// RunStateWaitingPermission 表示当前正在等待权限决议,执行流被显式挂起。
RunStateWaitingPermission RunState = "waiting_permission"
// RunStateStopped 表示本次 Run 已完成终止决议,不再继续推进生命周期。
RunStateStopped RunState = "stopped"
)

var allowedRunStateTransitions = map[RunState]map[RunState]struct{}{
"": {
RunStatePlan: {},
},
RunStatePlan: {
RunStatePlan: {},
RunStateExecute: {},
RunStateCompacting: {},
RunStateWaitingPermission: {},
RunStateStopped: {},
},
RunStateExecute: {
RunStateExecute: {},
RunStateVerify: {},
RunStateCompacting: {},
RunStateWaitingPermission: {},
RunStateStopped: {},
},
RunStateVerify: {
RunStateVerify: {},
RunStatePlan: {},
RunStateCompacting: {},
RunStateWaitingPermission: {},
RunStateStopped: {},
},
RunStateCompacting: {
RunStateCompacting: {},
RunStatePlan: {},
RunStateWaitingPermission: {},
RunStateStopped: {},
},
RunStateWaitingPermission: {
RunStateWaitingPermission: {},
RunStatePlan: {},
RunStateExecute: {},
RunStateVerify: {},
RunStateCompacting: {},
RunStateStopped: {},
},
RunStateStopped: {
RunStateStopped: {},
},
}

// ValidateRunStateTransition 校验生命周期迁移是否合法,避免主链 phase 与外围治理态分裂成多套规则。
func ValidateRunStateTransition(from RunState, to RunState) error {
if nextStates, ok := allowedRunStateTransitions[from]; ok {
if _, allowed := nextStates[to]; allowed {
return nil
}
}
return fmt.Errorf("runtime: invalid run state transition %q -> %q", from, to)
}
40 changes: 40 additions & 0 deletions internal/runtime/controlplane/phase_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package controlplane

import "testing"

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

validTransitions := []struct {
from RunState
to RunState
}{
{from: "", to: RunStatePlan},
{from: RunStatePlan, to: RunStateExecute},
{from: RunStateExecute, to: RunStateVerify},
{from: RunStateVerify, to: RunStatePlan},
{from: RunStatePlan, to: RunStateCompacting},
{from: RunStateCompacting, to: RunStatePlan},
{from: RunStateExecute, to: RunStateWaitingPermission},
{from: RunStateWaitingPermission, to: RunStateExecute},
{from: RunStateVerify, to: RunStateStopped},
}

for _, tc := range validTransitions {
tc := tc
t.Run(string(tc.from)+"->"+string(tc.to), func(t *testing.T) {
t.Parallel()
if err := ValidateRunStateTransition(tc.from, tc.to); err != nil {
t.Fatalf("ValidateRunStateTransition(%q,%q) error = %v", tc.from, tc.to, err)
}
})
}
}

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

if err := ValidateRunStateTransition(RunStatePlan, RunStateVerify); err == nil {
t.Fatalf("expected invalid transition to return error")
}
}
Loading
Loading