-
Notifications
You must be signed in to change notification settings - Fork 7
feat(): 实现 SubAgent WorkerRuntime 与角色策略(#274) #295
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
2573fd1
feat(): 实现 SubAgent WorkerRuntime、角色策略与 Runtime 生命周期接入
Cai-Tang-www 9384e80
fix(subagent): resolve review conflicts and strengthen coverage
xgopilot cc74bd4
fix(runtime): resolve merge conflicts with main event/runtime changes
xgopilot 730a61b
fix(runtime): align runtime core with main to resolve merge conflicts
xgopilot a5bf778
refactor(runtime): decouple subagent factory storage from service struct
xgopilot e43f70c
fix(subagent): resolve review issues for factory lifecycle and defaul…
xgopilot 062846d
fix(runtime): resolve main merge conflicts for subagent runtime
xgopilot 631d8ab
refactor(runtime): align state model with main-compatible event envelope
xgopilot 1afecba
test(runtime): align helper tests with main to remove merge conflicts
xgopilot 727ed3f
fix(subagent): address review gaps in progress envelope and capabilit…
xgopilot 5202821
Merge branch 'main' into feat(subagent)
phantom5099 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| package runtime | ||
|
|
||
| import "neo-code/internal/subagent" | ||
|
|
||
| // EventPermissionRequest 为兼容旧事件名保留,语义等同 EventPermissionRequested。 | ||
| const EventPermissionRequest EventType = EventPermissionRequested | ||
|
|
||
| // EventCompactDone 为兼容旧事件名保留,语义等同 EventCompactApplied。 | ||
| const EventCompactDone EventType = EventCompactApplied | ||
|
|
||
| // SubAgentEventPayload 描述子代理执行生命周期的事件载荷。 | ||
| type SubAgentEventPayload struct { | ||
| Role subagent.Role `json:"role"` | ||
| TaskID string `json:"task_id"` | ||
| State subagent.State `json:"state"` | ||
| StopReason subagent.StopReason `json:"stop_reason,omitempty"` | ||
| Step int `json:"step,omitempty"` | ||
| Delta string `json:"delta,omitempty"` | ||
| Error string `json:"error,omitempty"` | ||
| } | ||
|
|
||
| const ( | ||
| // EventSubAgentStarted 在子代理任务启动后触发。 | ||
| EventSubAgentStarted EventType = "subagent_started" | ||
| // EventSubAgentProgress 在子代理执行每一步后触发。 | ||
| EventSubAgentProgress EventType = "subagent_progress" | ||
| // EventSubAgentCompleted 在子代理成功结束后触发。 | ||
| EventSubAgentCompleted EventType = "subagent_completed" | ||
| // EventSubAgentFailed 在子代理失败结束后触发。 | ||
| EventSubAgentFailed EventType = "subagent_failed" | ||
| // EventSubAgentCanceled 在子代理被取消后触发。 | ||
| EventSubAgentCanceled EventType = "subagent_canceled" | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| package runtime | ||
|
|
||
| // defaultMaxLoops 定义兼容旧运行循环逻辑时的默认最大轮数。 | ||
| const defaultMaxLoops = 8 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| package runtime | ||
|
|
||
| import ( | ||
| "runtime" | ||
| "sync" | ||
|
|
||
| "neo-code/internal/subagent" | ||
| ) | ||
|
|
||
| type subAgentFactoryRegistry struct { | ||
| mu sync.RWMutex | ||
| factory map[*Service]subagent.Factory | ||
| tracked map[*Service]struct{} | ||
| } | ||
|
|
||
| var globalSubAgentFactories = &subAgentFactoryRegistry{ | ||
| factory: make(map[*Service]subagent.Factory), | ||
| tracked: make(map[*Service]struct{}), | ||
| } | ||
|
|
||
| // ensureTracked 为 Service 注册 GC 回调,避免全局注册表持有泄漏。 | ||
| func (r *subAgentFactoryRegistry) ensureTracked(s *Service) { | ||
| if s == nil { | ||
| return | ||
| } | ||
|
|
||
| r.mu.Lock() | ||
| if _, ok := r.tracked[s]; ok { | ||
| r.mu.Unlock() | ||
| return | ||
| } | ||
| r.tracked[s] = struct{}{} | ||
| r.mu.Unlock() | ||
|
|
||
| runtime.SetFinalizer(s, func(service *Service) { | ||
| globalSubAgentFactories.mu.Lock() | ||
| delete(globalSubAgentFactories.factory, service) | ||
| delete(globalSubAgentFactories.tracked, service) | ||
| globalSubAgentFactories.mu.Unlock() | ||
| }) | ||
| } | ||
|
|
||
| // set 保存 Service 级工厂实例。 | ||
| func (r *subAgentFactoryRegistry) set(s *Service, f subagent.Factory) { | ||
| if s == nil { | ||
| return | ||
| } | ||
| r.ensureTracked(s) | ||
|
|
||
| r.mu.Lock() | ||
| r.factory[s] = f | ||
| r.mu.Unlock() | ||
| } | ||
|
|
||
| // get 读取 Service 级工厂实例。 | ||
| func (r *subAgentFactoryRegistry) get(s *Service) (subagent.Factory, bool) { | ||
| if s == nil { | ||
| return nil, false | ||
| } | ||
| r.ensureTracked(s) | ||
|
|
||
| r.mu.RLock() | ||
| factory, ok := r.factory[s] | ||
| r.mu.RUnlock() | ||
| return factory, ok | ||
| } | ||
|
|
||
| // defaultSubAgentFactory 返回默认的子代理工厂实例。 | ||
| func defaultSubAgentFactory() subagent.Factory { | ||
| return subagent.NewWorkerFactory(nil) | ||
| } | ||
|
|
||
| // SetSubAgentFactory 设置子代理运行时工厂;传入 nil 时回退到默认工厂。 | ||
| func (s *Service) SetSubAgentFactory(factory subagent.Factory) { | ||
| if s == nil { | ||
| return | ||
| } | ||
| if factory == nil { | ||
| globalSubAgentFactories.set(s, defaultSubAgentFactory()) | ||
| return | ||
| } | ||
| globalSubAgentFactories.set(s, factory) | ||
| } | ||
|
|
||
| // SubAgentFactory 返回当前 runtime 持有的子代理运行时工厂。 | ||
| func (s *Service) SubAgentFactory() subagent.Factory { | ||
| if s == nil { | ||
| return defaultSubAgentFactory() | ||
| } | ||
| if factory, ok := globalSubAgentFactories.get(s); ok && factory != nil { | ||
| return factory | ||
| } | ||
|
|
||
| defaultFactory := defaultSubAgentFactory() | ||
| globalSubAgentFactories.set(s, defaultFactory) | ||
| return defaultFactory | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| package runtime | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "neo-code/internal/subagent" | ||
| ) | ||
|
|
||
| type fakeSubAgentFactory struct{} | ||
|
|
||
| func (fakeSubAgentFactory) Create(role subagent.Role) (subagent.WorkerRuntime, error) { | ||
| return subagent.NewWorkerFactory(nil).Create(role) | ||
| } | ||
|
|
||
| func TestServiceSubAgentFactoryRegistration(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| svc := NewWithFactory(nil, nil, nil, nil, nil) | ||
| if svc.SubAgentFactory() == nil { | ||
| t.Fatalf("expected default sub-agent factory") | ||
| } | ||
|
|
||
| custom := fakeSubAgentFactory{} | ||
| svc.SetSubAgentFactory(custom) | ||
| if svc.SubAgentFactory() == nil { | ||
| t.Fatalf("expected custom sub-agent factory") | ||
| } | ||
|
|
||
| svc.SetSubAgentFactory(nil) | ||
| if svc.SubAgentFactory() == nil { | ||
| t.Fatalf("expected reset to default sub-agent factory") | ||
| } | ||
| } | ||
|
|
||
| func TestServiceSubAgentFactoryIsolationAcrossInstances(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| svcA := NewWithFactory(nil, nil, nil, nil, nil) | ||
| svcB := NewWithFactory(nil, nil, nil, nil, nil) | ||
|
|
||
| custom := fakeSubAgentFactory{} | ||
| svcA.SetSubAgentFactory(custom) | ||
|
|
||
| if svcA.SubAgentFactory() == nil { | ||
| t.Fatalf("expected service A factory to be set") | ||
| } | ||
| if svcB.SubAgentFactory() == nil { | ||
| t.Fatalf("expected service B default factory") | ||
| } | ||
|
|
||
| if svcA.SubAgentFactory() == svcB.SubAgentFactory() { | ||
| t.Fatalf("expected per-service factory isolation") | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| package runtime | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "neo-code/internal/runtime/controlplane" | ||
| "neo-code/internal/subagent" | ||
| ) | ||
|
|
||
| // SubAgentTaskInput 描述一次子代理任务执行请求。 | ||
| type SubAgentTaskInput struct { | ||
| RunID string | ||
| SessionID string | ||
| Role subagent.Role | ||
| Task subagent.Task | ||
| Budget subagent.Budget | ||
| Capability subagent.Capability | ||
| } | ||
|
|
||
| // RunSubAgentTask 使用当前 runtime 注册的工厂执行一条子代理任务。 | ||
| func (s *Service) RunSubAgentTask(ctx context.Context, input SubAgentTaskInput) (subagent.Result, error) { | ||
| if err := ctx.Err(); err != nil { | ||
| return subagent.Result{}, err | ||
| } | ||
| if strings.TrimSpace(input.RunID) == "" { | ||
| return subagent.Result{}, errors.New("runtime: subagent run id is empty") | ||
| } | ||
| if !input.Role.Valid() { | ||
| return subagent.Result{}, fmt.Errorf("runtime: invalid subagent role %q", input.Role) | ||
| } | ||
| if err := input.Task.Validate(); err != nil { | ||
| return subagent.Result{}, err | ||
| } | ||
|
|
||
| factory := s.SubAgentFactory() | ||
| worker, err := factory.Create(input.Role) | ||
| if err != nil { | ||
| _ = s.emit(ctx, EventSubAgentFailed, input.RunID, input.SessionID, SubAgentEventPayload{ | ||
| Role: input.Role, | ||
| TaskID: input.Task.ID, | ||
| State: subagent.StateFailed, | ||
| Error: err.Error(), | ||
| }) | ||
| return subagent.Result{}, err | ||
| } | ||
|
|
||
| if err := worker.Start(input.Task, input.Budget, input.Capability); err != nil { | ||
| _ = s.emit(ctx, EventSubAgentFailed, input.RunID, input.SessionID, SubAgentEventPayload{ | ||
| Role: input.Role, | ||
| TaskID: input.Task.ID, | ||
| State: subagent.StateFailed, | ||
| Error: err.Error(), | ||
| }) | ||
| return subagent.Result{}, err | ||
| } | ||
|
|
||
| _ = s.emit(ctx, EventSubAgentStarted, input.RunID, input.SessionID, SubAgentEventPayload{ | ||
| Role: input.Role, | ||
| TaskID: input.Task.ID, | ||
| State: worker.State(), | ||
| }) | ||
|
|
||
| for { | ||
| stepResult, stepErr := worker.Step(ctx) | ||
| if stepResult.State == "" { | ||
| stepResult.State = worker.State() | ||
| } | ||
| emitSubAgentProgress(s, input, stepResult, stepErr) | ||
|
|
||
| if stepErr != nil { | ||
| if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) { | ||
| _ = worker.Stop(subagent.StopReasonCanceled) | ||
| result, resultErr := worker.Result() | ||
| if resultErr != nil { | ||
| result = subagent.Result{ | ||
| Role: input.Role, | ||
| TaskID: input.Task.ID, | ||
| State: subagent.StateCanceled, | ||
| StopReason: subagent.StopReasonCanceled, | ||
| Error: errorText(stepErr), | ||
| } | ||
| } | ||
| emitSubAgentTerminal(s, ctx, input, result) | ||
| return result, stepErr | ||
| } | ||
|
|
||
| result, resultErr := worker.Result() | ||
| if resultErr != nil { | ||
| _ = s.emit(ctx, EventSubAgentFailed, input.RunID, input.SessionID, SubAgentEventPayload{ | ||
| Role: input.Role, | ||
| TaskID: input.Task.ID, | ||
| State: subagent.StateFailed, | ||
| Error: stepErr.Error(), | ||
| }) | ||
| return subagent.Result{}, stepErr | ||
| } | ||
| emitSubAgentTerminal(s, ctx, input, result) | ||
| return result, stepErr | ||
| } | ||
|
|
||
| if !stepResult.Done { | ||
| continue | ||
| } | ||
|
|
||
| result, err := worker.Result() | ||
| if err != nil { | ||
| _ = s.emit(ctx, EventSubAgentFailed, input.RunID, input.SessionID, SubAgentEventPayload{ | ||
| Role: input.Role, | ||
| TaskID: input.Task.ID, | ||
| State: subagent.StateFailed, | ||
| Error: err.Error(), | ||
| }) | ||
| return subagent.Result{}, err | ||
| } | ||
| emitSubAgentTerminal(s, ctx, input, result) | ||
| if result.State == subagent.StateSucceeded { | ||
| return result, nil | ||
| } | ||
| return result, subAgentResultError(result) | ||
| } | ||
| } | ||
|
|
||
| // emitSubAgentProgress 非阻塞发射进度事件,避免慢消费者反压执行路径。 | ||
| func emitSubAgentProgress(s *Service, input SubAgentTaskInput, stepResult subagent.StepResult, stepErr error) { | ||
| payload := SubAgentEventPayload{ | ||
| Role: input.Role, | ||
| TaskID: input.Task.ID, | ||
| State: stepResult.State, | ||
| Step: stepResult.Step, | ||
| Delta: stepResult.Delta, | ||
| Error: errorText(stepErr), | ||
| } | ||
| event := RuntimeEvent{ | ||
| Type: EventSubAgentProgress, | ||
| RunID: input.RunID, | ||
| SessionID: input.SessionID, | ||
| Turn: turnUnspecified, | ||
| Timestamp: time.Now(), | ||
| PayloadVersion: controlplane.PayloadVersion, | ||
| Payload: payload, | ||
| } | ||
| select { | ||
| case s.events <- event: | ||
| default: | ||
| } | ||
| } | ||
|
|
||
| // emitSubAgentTerminal 按子代理终态发射最终事件。 | ||
| func emitSubAgentTerminal(s *Service, ctx context.Context, input SubAgentTaskInput, result subagent.Result) { | ||
| payload := SubAgentEventPayload{ | ||
| Role: result.Role, | ||
| TaskID: result.TaskID, | ||
| State: result.State, | ||
| StopReason: result.StopReason, | ||
| Step: result.StepCount, | ||
| Error: strings.TrimSpace(result.Error), | ||
| } | ||
|
|
||
| switch result.State { | ||
| case subagent.StateSucceeded: | ||
| _ = s.emit(ctx, EventSubAgentCompleted, input.RunID, input.SessionID, payload) | ||
| case subagent.StateCanceled: | ||
| _ = s.emit(ctx, EventSubAgentCanceled, input.RunID, input.SessionID, payload) | ||
| default: | ||
| _ = s.emit(ctx, EventSubAgentFailed, input.RunID, input.SessionID, payload) | ||
| } | ||
| } | ||
|
|
||
| // errorText 将 error 安全转换为事件可用文本。 | ||
| func errorText(err error) string { | ||
| if err == nil { | ||
| return "" | ||
| } | ||
| return strings.TrimSpace(err.Error()) | ||
| } | ||
|
|
||
| // subAgentResultError 将子代理终态结果转换为可诊断错误,避免空错误文本丢失上下文。 | ||
| func subAgentResultError(result subagent.Result) error { | ||
| if text := strings.TrimSpace(result.Error); text != "" { | ||
| return errors.New(text) | ||
| } | ||
| return fmt.Errorf("subagent ended with state=%s stop_reason=%s", result.State, result.StopReason) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.