diff --git a/.agents/skills/pr-fixup/SKILL.md b/.agents/skills/pr-fixup/SKILL.md index 968ee1ab6e..32e32c5479 100644 --- a/.agents/skills/pr-fixup/SKILL.md +++ b/.agents/skills/pr-fixup/SKILL.md @@ -22,12 +22,13 @@ replies, and refresh current-head state after pushes and review aggregation. Create a visible checklist: 1. Gather PR state -2. Fix failing CI checks -3. Triage review comments -4. Address valid comments -5. Commit, rerun affected checks, and push -6. Re-check the new head -7. Report +2. Resolve an authorized PR merge conflict +3. Fix failing CI checks +4. Triage review comments +5. Address valid comments +6. Commit, rerun affected checks, and push +7. Re-check the new head +8. Report ## 1. Gather PR State @@ -45,10 +46,14 @@ classification, authentication fallbacks, and hidden-thread handling. Inspect mergeability separately through `references/merge-conflicts.md`. If the fresh mergeability query reports `mergeable=CONFLICTING` or -`mergeStateStatus=DIRTY`, stop CI and review triage. Load -`references/merge-conflicts.md`, resolve or rebase the conflict, verify the -result, push it, and restart this section with a new PR-state snapshot. Do not -triage comments or checks against a conflicted head. +`mergeStateStatus=DIRTY`, stop CI and review triage. This is an actionable PR +blocker, not a clean or report-only terminal state. Load +`references/merge-conflicts.md`. If the user has explicitly authorized PR +fixup or conflict resolution, resolve the conflict now (prefer a merge of the +fresh base unless the user requests a rebase), verify the result, push it, and +restart this section with a new PR-state snapshot. If authorization is absent, +report the conflict and ask for it before mutating the branch. Do not triage +comments or checks against a conflicted head. For pending CI, wait with `scripts/pr-await `. It blocks below the conversation and prints one report when every check is terminal, so a wait of diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go index 0be4c416ee..13a4cc58ee 100644 --- a/apps/backend/internal/agent/agents/agent.go +++ b/apps/backend/internal/agent/agents/agent.go @@ -63,6 +63,21 @@ type Agent interface { InstallScript() string } +// VirtualAgent marks an agent family that is visible to settings and profile +// configuration but cannot launch an inference process itself. +type VirtualAgent interface { + Agent + IsVirtual() bool +} + +// IsVirtualAgent reports whether an agent is a non-launchable virtual family. +// Keeping this as an optional capability lets existing concrete agents remain +// unchanged while callers can fail closed at launch and discovery boundaries. +func IsVirtualAgent(agent Agent) bool { + virtual, ok := agent.(VirtualAgent) + return ok && virtual.IsVirtual() +} + // InferenceAgent is an optional capability marker for agents that support // one-shot LLM inference via the host utility manager. The actual model list // is populated dynamically from the ACP probe — agents no longer declare a diff --git a/apps/backend/internal/agent/agents/dynamic.go b/apps/backend/internal/agent/agents/dynamic.go new file mode 100644 index 0000000000..3f51921523 --- /dev/null +++ b/apps/backend/internal/agent/agents/dynamic.go @@ -0,0 +1,53 @@ +package agents + +import ( + "context" + + "github.com/kandev/kandev/internal/agent/usage" +) + +const ( + // DynamicAgentID is the stable registry and settings-family identity for + // dynamic profiles. It is also used as the durable parent-agent row ID. + DynamicAgentID = "dynamic" + + dynamicAgentName = "Dynamic" + dynamicAgentDescription = "Routes one logical session through ordered concrete agent profiles." +) + +var _ VirtualAgent = (*DynamicAgent)(nil) + +// DynamicAgent is the built-in virtual family for dynamic profiles. It is +// intentionally not an InferenceAgent: a dynamic profile resolves a concrete +// candidate before any launch or probe occurs. +type DynamicAgent struct{} + +func NewDynamicAgent() *DynamicAgent { return &DynamicAgent{} } + +func (a *DynamicAgent) ID() string { return DynamicAgentID } +func (a *DynamicAgent) Name() string { return dynamicAgentName } +func (a *DynamicAgent) DisplayName() string { return dynamicAgentName } +func (a *DynamicAgent) Description() string { return dynamicAgentDescription } +func (a *DynamicAgent) Enabled() bool { return true } +func (a *DynamicAgent) DisplayOrder() int { return -1 } +func (a *DynamicAgent) Logo(LogoVariant) []byte { return nil } + +func (a *DynamicAgent) IsInstalled(context.Context) (*DiscoveryResult, error) { + return nil, ErrNotSupported +} + +func (a *DynamicAgent) BuildCommand(CommandOptions) Command { return Command{} } + +func (a *DynamicAgent) PermissionSettings() map[string]PermissionSetting { + return emptyPermSettings +} + +func (a *DynamicAgent) Runtime() *RuntimeConfig { return nil } + +func (a *DynamicAgent) BillingType() usage.BillingType { return usage.BillingTypeAPIKey } + +func (a *DynamicAgent) RemoteAuth() *RemoteAuth { return nil } + +func (a *DynamicAgent) InstallScript() string { return "" } + +func (a *DynamicAgent) IsVirtual() bool { return true } diff --git a/apps/backend/internal/agent/discovery/discovery.go b/apps/backend/internal/agent/discovery/discovery.go index 44479b81c4..d097346dbb 100644 --- a/apps/backend/internal/agent/discovery/discovery.go +++ b/apps/backend/internal/agent/discovery/discovery.go @@ -70,6 +70,9 @@ func LoadRegistry(ctx context.Context, reg *registry.Registry, log *logger.Logge agentList := make([]agents.Agent, 0, len(enabled)) for _, ag := range enabled { + if agents.IsVirtualAgent(ag) { + continue + } // Gather discovery info from the agent. result, err := ag.IsInstalled(ctx) if err != nil { diff --git a/apps/backend/internal/agent/registry/provider.go b/apps/backend/internal/agent/registry/provider.go index 55cd4e5ae3..63006e880c 100644 --- a/apps/backend/internal/agent/registry/provider.go +++ b/apps/backend/internal/agent/registry/provider.go @@ -17,7 +17,8 @@ import ( // Provide creates and loads the agent registry. // // KANDEV_MOCK_AGENT controls mock-agent availability: -// - "only" → E2E mode: only register mock agent, skip all others +// - "only" → E2E mode: register virtual families and the mock agent, +// skip all other inference agents // - "true" → Dev mode: load all agents AND enable mock agent // - unset → Production: load all agents, mock agent disabled // @@ -39,7 +40,11 @@ func Provide(log *logger.Logger) (*Registry, func() error, error) { mockMode := os.Getenv("KANDEV_MOCK_AGENT") mockProviders := os.Getenv("KANDEV_MOCK_PROVIDERS") if mockMode == "only" { - // E2E mode: only register mock agent — skip agent discovery for all others + // E2E mode keeps inference isolated to the mock provider, but virtual + // families are still part of the settings and routing contract. They do + // not launch a process or discover a host binary, so retaining them does + // not weaken the isolation boundary. + _ = reg.Register(agents.NewDynamicAgent()) _ = reg.Register(agents.NewMockAgent()) configureMockAgent(reg, "mock-agent", log) registerExtraMockProviders(reg, log, mockProviders) diff --git a/apps/backend/internal/agent/registry/provider_test.go b/apps/backend/internal/agent/registry/provider_test.go index f313578b15..f473965867 100644 --- a/apps/backend/internal/agent/registry/provider_test.go +++ b/apps/backend/internal/agent/registry/provider_test.go @@ -45,7 +45,7 @@ func TestProvide_MockAgentModes(t *testing.T) { name string envValue string wantMockEnabled bool - wantOnlyMock bool // only mock-agent registered (no other defaults) + wantOnlyMock bool // only mock inference agent registered (virtual families remain) wantDefaultsCount int // minimum expected agent count (0 = don't check) }{ { @@ -102,10 +102,13 @@ func TestProvide_MockAgentModes(t *testing.T) { // Check agent count all := reg.List() if tt.wantOnlyMock { - if len(all) != 1 { - t.Errorf("only mode: expected 1 agent, got %d", len(all)) + if len(all) != 2 { + t.Errorf("only mode: expected mock agent plus virtual families, got %d", len(all)) } } + if !reg.Exists(agents.DynamicAgentID) { + t.Error("dynamic virtual family should always be registered") + } if tt.wantDefaultsCount > 0 && len(all) < tt.wantDefaultsCount { t.Errorf("expected at least %d agents, got %d", tt.wantDefaultsCount, len(all)) } diff --git a/apps/backend/internal/agent/registry/registry.go b/apps/backend/internal/agent/registry/registry.go index bb91e3773d..7fbf6c4dbf 100644 --- a/apps/backend/internal/agent/registry/registry.go +++ b/apps/backend/internal/agent/registry/registry.go @@ -64,6 +64,7 @@ func NewRegistry(log *logger.Logger) *Registry { // LoadDefaults loads default agent configurations func (r *Registry) LoadDefaults() { all := []agents.Agent{ + agents.NewDynamicAgent(), agents.NewAuggie(), agents.NewClaudeACP(), agents.NewCodexACP(), @@ -134,7 +135,7 @@ func (r *Registry) GetDefault() (agents.Agent, error) { // Collect enabled agents and sort by DisplayOrder for deterministic fallback var enabled []agents.Agent for _, ag := range r.agents { - if ag.Enabled() { + if ag.Enabled() && !agents.IsVirtualAgent(ag) { enabled = append(enabled, ag) } } diff --git a/apps/backend/internal/agent/registry/registry_test.go b/apps/backend/internal/agent/registry/registry_test.go index d7a2572ea9..02a62b6e0d 100644 --- a/apps/backend/internal/agent/registry/registry_test.go +++ b/apps/backend/internal/agent/registry/registry_test.go @@ -257,6 +257,25 @@ func TestRegistry_LoadDefaults(t *testing.T) { if !reg.Exists("grok-acp") { t.Error("expected default agent 'grok-acp' to be loaded") } + if !reg.Exists(agents.DynamicAgentID) { + t.Errorf("expected virtual agent family %q to be loaded", agents.DynamicAgentID) + } + for _, ia := range reg.ListInferenceAgents() { + if ia.(agents.Agent).ID() == agents.DynamicAgentID { + t.Fatal("dynamic virtual family must not be exposed as an inference agent") + } + } +} + +func TestRegistry_GetDefaultDoesNotSelectVirtualFamily(t *testing.T) { + log := newTestLogger() + reg := NewRegistry(log) + if err := reg.Register(agents.NewDynamicAgent()); err != nil { + t.Fatalf("register dynamic family: %v", err) + } + if _, err := reg.GetDefault(); err == nil { + t.Fatal("GetDefault selected the non-launchable dynamic family") + } } func TestRegistry_List_OrderedByDisplayOrder(t *testing.T) { diff --git a/apps/backend/internal/agent/remoteauth/catalog.go b/apps/backend/internal/agent/remoteauth/catalog.go index 35ccaebd18..373420d730 100644 --- a/apps/backend/internal/agent/remoteauth/catalog.go +++ b/apps/backend/internal/agent/remoteauth/catalog.go @@ -72,6 +72,9 @@ func BuildCatalogForHost(enabledAgents []agents.Agent, currentOS, homeDir string } for _, ag := range enabledAgents { + if agents.IsVirtualAgent(ag) { + continue + } auth := ag.RemoteAuth() if auth == nil { continue diff --git a/apps/backend/internal/agent/runtime/dynamic/circuit.go b/apps/backend/internal/agent/runtime/dynamic/circuit.go new file mode 100644 index 0000000000..b3b6b84748 --- /dev/null +++ b/apps/backend/internal/agent/runtime/dynamic/circuit.go @@ -0,0 +1,192 @@ +package dynamic + +import ( + "context" + "sync" + "time" + + "github.com/kandev/kandev/internal/agent/runtime/routingerr" +) + +type CircuitState string + +const ( + CircuitClosed CircuitState = "closed" + CircuitOpen CircuitState = "open" + CircuitHalfOpen CircuitState = "half_open" +) + +type ResourceScope string + +const ( + ScopeProvider ResourceScope = "provider" + ScopeCredential ResourceScope = "credential" + ScopeModel ResourceScope = "model" + ScopeProfile ResourceScope = "profile" +) + +func ResourceKey(scope ResourceScope, fingerprint string) string { + return string(scope) + ":" + fingerprint +} + +type circuit struct { + state CircuitState + until time.Time + code routingerr.Code + probeUntil time.Time +} + +// CircuitSnapshot is the durable representation of one resource circuit. +// Keys are opaque fingerprints and must already be safe to persist. +type CircuitSnapshot struct { + Key string + State CircuitState + Until time.Time + Code routingerr.Code + ProbeUntil time.Time +} + +// CircuitPersistence stores shared resource health across backend restarts. +type CircuitPersistence interface { + SaveCircuit(context.Context, CircuitSnapshot) error + LoadCircuits(context.Context) ([]CircuitSnapshot, error) +} + +type CircuitRegistryOption func(*CircuitRegistry) + +func WithCircuitClock(now func() time.Time) CircuitRegistryOption { + return func(registry *CircuitRegistry) { + if now != nil { + registry.now = now + } + } +} + +func WithCircuitPersistence(persistence CircuitPersistence) CircuitRegistryOption { + return func(registry *CircuitRegistry) { registry.persist = persistence } +} + +type CircuitRegistry struct { + mu sync.Mutex + now func() time.Time + circuits map[string]circuit + persist CircuitPersistence +} + +func NewCircuitRegistry(options ...CircuitRegistryOption) *CircuitRegistry { + registry := &CircuitRegistry{now: time.Now, circuits: make(map[string]circuit)} + for _, option := range options { + option(registry) + } + return registry +} + +// Restore loads durable circuit state before routing workers start. +func (r *CircuitRegistry) Restore(ctx context.Context) error { + if r.persist == nil { + return nil + } + snapshots, err := r.persist.LoadCircuits(ctx) + if err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + for _, snapshot := range snapshots { + r.circuits[snapshot.Key] = circuit{ + state: snapshot.State, until: snapshot.Until, + code: snapshot.Code, probeUntil: snapshot.ProbeUntil, + } + } + return nil +} + +func (r *CircuitRegistry) Open(key string, until time.Time, code routingerr.Code) { + if key == "" { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.circuits[key] = circuit{state: CircuitOpen, until: until, code: code} + r.persistSnapshotLocked(key) +} + +func (r *CircuitRegistry) IsOpen(key string, now time.Time) bool { + r.mu.Lock() + defer r.mu.Unlock() + entry, ok := r.circuits[key] + if !ok || entry.state == CircuitClosed { + return false + } + if entry.state == CircuitHalfOpen { + return true + } + // An expired open circuit remains unavailable until one caller acquires + // the exclusive probe lease. Treating it as closed here would let every + // selector stampede the provider between expiry and probe acquisition. + return entry.state == CircuitOpen && !entry.until.IsZero() +} + +type ProbeLease struct { + Key string + ExpiresAt time.Time +} + +func (r *CircuitRegistry) AcquireProbe(key string, duration time.Duration) (ProbeLease, bool) { + if key == "" || duration <= 0 { + return ProbeLease{}, false + } + r.mu.Lock() + defer r.mu.Unlock() + now := r.now() + entry, ok := r.circuits[key] + if !ok || entry.state == CircuitClosed || now.Before(entry.until) || now.Before(entry.probeUntil) { + return ProbeLease{}, false + } + lease := ProbeLease{Key: key, ExpiresAt: now.Add(duration)} + entry.state = CircuitHalfOpen + entry.probeUntil = lease.ExpiresAt + r.circuits[key] = entry + r.persistSnapshotLocked(key) + return lease, true +} + +func (r *CircuitRegistry) ReleaseProbe(lease ProbeLease, success bool, backoff time.Duration) { + if lease.Key == "" { + return + } + r.mu.Lock() + defer r.mu.Unlock() + entry, ok := r.circuits[lease.Key] + if !ok || entry.state != CircuitHalfOpen || !entry.probeUntil.Equal(lease.ExpiresAt) { + return + } + entry.probeUntil = time.Time{} + if success { + entry.state = CircuitClosed + entry.until = time.Time{} + entry.code = "" + } else { + entry.state = CircuitOpen + entry.until = r.now().Add(backoff) + } + r.circuits[lease.Key] = entry + r.persistSnapshotLocked(lease.Key) +} + +func (r *CircuitRegistry) persistSnapshotLocked(key string) { + if r.persist == nil { + return + } + entry, ok := r.circuits[key] + if !ok { + return + } + // Selection remains fail-closed in memory if persistence is temporarily + // unavailable. Startup Restore and health reconciliation surface durable + // read failures to their caller. + _ = r.persist.SaveCircuit(context.Background(), CircuitSnapshot{ + Key: key, State: entry.state, Until: entry.until, + Code: entry.code, ProbeUntil: entry.probeUntil, + }) +} diff --git a/apps/backend/internal/agent/runtime/dynamic/conductor.go b/apps/backend/internal/agent/runtime/dynamic/conductor.go new file mode 100644 index 0000000000..e91f363de0 --- /dev/null +++ b/apps/backend/internal/agent/runtime/dynamic/conductor.go @@ -0,0 +1,463 @@ +package dynamic + +import ( + "context" + "errors" + "strings" + "sync" + "time" + + "github.com/kandev/kandev/internal/agent/runtime/routingerr" +) + +// DownstreamLaunch is the only launch input a dynamic conductor gives to a +// concrete runtime. In particular, a provider-native resume token is never +// sent to a different candidate. +type DownstreamLaunch struct { + ExecutionProfileID string + Decision RouteDecision + Prompt string + PriorACPSession string + Continuation Continuation +} + +type DownstreamExecution struct { + ID string + ExecutionProfileID string + ACPSessionID string +} + +type DownstreamRuntime interface { + Launch(context.Context, DownstreamLaunch) (DownstreamExecution, error) + Resume(context.Context, string, string) error + Stop(context.Context, string, string) error +} + +// DownstreamExecutionLoader restores the concrete execution identity after a +// process restart. Implementations should read the durable task-session row, +// not a process-local cache. +type DownstreamExecutionLoader interface { + LoadExecution(context.Context, string) (DownstreamExecution, bool, error) +} + +type ProfileLoader interface { + LoadDynamicProfile(context.Context, string) (Profile, error) +} + +type ConductorOption func(*Conductor) + +func WithContinuationBuilder(builder func(context.Context, ContinuationInput) (Continuation, error)) ConductorOption { + return func(conductor *Conductor) { conductor.continuationBuilder = builder } +} + +func WithContinuationPersistence(persistence ContinuationPersistence) ConductorOption { + return func(conductor *Conductor) { conductor.continuationPersistence = persistence } +} + +// Conductor owns the logical session while concrete runtimes own downstream +// process and ACP identities. +type Conductor struct { + mu sync.Mutex + engine *Engine + profiles ProfileLoader + downstream DownstreamRuntime + continuationBuilder func(context.Context, ContinuationInput) (Continuation, error) + continuationPersistence ContinuationPersistence + active map[string]DownstreamExecution +} + +func NewConductor(engine *Engine, profiles ProfileLoader, downstream DownstreamRuntime, options ...ConductorOption) *Conductor { + conductor := &Conductor{ + engine: engine, profiles: profiles, downstream: downstream, + active: make(map[string]DownstreamExecution), + } + for _, option := range options { + option(conductor) + } + return conductor +} + +type ConductorLaunch struct { + SessionID string + LogicalProfileID string + ExpectedGeneration int64 + ExcludeProfileID string + Prompt string + PriorACPSession string + Continuation ContinuationInput +} + +// ConductorSelectedLaunch hands the conductor a route that was already +// claimed by the shared resolver. It is used by lifecycle adapters that must +// preserve the existing session generation while still delegating classified +// pre-result fallback to the conductor. +type ConductorSelectedLaunch struct { + SessionID string + LogicalProfileID string + Decision RouteDecision + Prompt string + PriorACPSession string + Continuation ContinuationInput + PrebuiltContinuation *Continuation +} + +type ConductorResult struct { + Decision RouteDecision + Execution DownstreamExecution + FreshRoute bool +} + +func (c *Conductor) Launch(ctx context.Context, request ConductorLaunch) (ConductorResult, error) { + if c.engine == nil || c.profiles == nil || c.downstream == nil { + return ConductorResult{}, errors.New("dynamic conductor is not configured") + } + profile, err := c.profiles.LoadDynamicProfile(ctx, request.LogicalProfileID) + if err != nil { + return ConductorResult{}, err + } + decision, err := c.engine.SelectContext(ctx, request.SessionID, profile, request.ExpectedGeneration, request.ExcludeProfileID) + if err != nil { + return ConductorResult{}, err + } + continuation, err := c.buildContinuation(ctx, request.Continuation) + if err != nil { + return ConductorResult{}, err + } + execution, decision, err := c.launchWithFallback(ctx, profile, decision, request, continuation) + if err != nil { + return ConductorResult{}, err + } + if execution.ExecutionProfileID == "" { + execution.ExecutionProfileID = decision.ExecutionProfileID + } + c.mu.Lock() + c.active[request.SessionID] = execution + c.mu.Unlock() + return ConductorResult{Decision: decision, Execution: execution, FreshRoute: true}, nil +} + +// LaunchSelected launches a previously claimed route and applies the same +// classified fallback policy as Launch. The caller owns durable session +// attribution; the downstream adapter receives each immutable decision before +// its corresponding launch. +func (c *Conductor) LaunchSelected(ctx context.Context, request ConductorSelectedLaunch) (ConductorResult, error) { + if c.engine == nil || c.profiles == nil || c.downstream == nil { + return ConductorResult{}, errors.New("dynamic conductor is not configured") + } + profile, err := c.profiles.LoadDynamicProfile(ctx, request.LogicalProfileID) + if err != nil { + return ConductorResult{}, err + } + var continuation Continuation + if request.PrebuiltContinuation != nil { + continuation = *request.PrebuiltContinuation + } else { + var err error + continuation, err = c.buildContinuation(ctx, request.Continuation) + if err != nil { + return ConductorResult{}, err + } + } + execution, decision, err := c.launchWithFallback( + ctx, + profile, + request.Decision, + ConductorLaunch{ + SessionID: request.SessionID, + LogicalProfileID: request.LogicalProfileID, + Prompt: request.Prompt, + PriorACPSession: request.PriorACPSession, + Continuation: request.Continuation, + }, + continuation, + ) + if err != nil { + return ConductorResult{}, err + } + if execution.ExecutionProfileID == "" { + execution.ExecutionProfileID = decision.ExecutionProfileID + } + c.mu.Lock() + c.active[request.SessionID] = execution + c.mu.Unlock() + return ConductorResult{Decision: decision, Execution: execution}, nil +} + +// RouteAfterFailure applies the logical profile's classified failure policy +// without launching a downstream process. Event handlers use it when a +// lifecycle error frame arrives after the initial launch. +func (c *Conductor) RouteAfterFailure( + ctx context.Context, + sessionID, logicalProfileID, currentExecutionProfileID string, + expectedGeneration int64, + failure *routingerr.Error, +) (RouteDecision, error) { + if c.engine == nil || c.profiles == nil { + return RouteDecision{}, errors.New("dynamic conductor is not configured") + } + profile, err := c.profiles.LoadDynamicProfile(ctx, logicalProfileID) + if err != nil { + return RouteDecision{}, err + } + return c.engine.ApplyFailureContext( + ctx, sessionID, profile, expectedGeneration, currentExecutionProfileID, failure, + ) +} + +// BuildContinuation creates the bounded provider-neutral handoff package used +// by a successor launch. Callers that already classified a failed turn build +// it before advancing the route, then persist it against the successor +// generation before launching that generation. +func (c *Conductor) BuildContinuation(ctx context.Context, input ContinuationInput) (Continuation, error) { + return c.buildContinuation(ctx, input) +} + +// PersistContinuation records a handoff package only after its successor +// generation has been claimed. The repository implementation fences the +// write to that generation. +func (c *Conductor) PersistContinuation(ctx context.Context, decision RouteDecision, continuation Continuation) error { + return c.persistContinuation(ctx, decision, continuation) +} + +func (c *Conductor) launchWithFallback( + ctx context.Context, + profile Profile, + decision RouteDecision, + request ConductorLaunch, + continuation Continuation, +) (DownstreamExecution, RouteDecision, error) { + maxAttempts := len(profile.Candidates) + if maxAttempts == 0 { + return DownstreamExecution{}, decision, ErrNoEligibleCandidate + } + current := decision + currentContinuation := continuation + for attempt := 0; attempt < maxAttempts; attempt++ { + execution, err := c.downstream.Launch(ctx, DownstreamLaunch{ + ExecutionProfileID: current.ExecutionProfileID, + Decision: current, + Prompt: ContinuationPrompt(request.Prompt, currentContinuation), + PriorACPSession: priorACPSession(request.PriorACPSession, decision, current), + Continuation: currentContinuation, + }) + if err == nil { + c.engine.ReleaseProbe(current, true) + return execution, current, nil + } + // Every probe attempt has a terminal launch result. Release it before + // applying policy so unclassified or post-start failures do not leave a + // half-open circuit lease stranded until its timeout. + c.engine.ReleaseProbe(current, false) + next, shouldFallback, fallbackErr := c.nextAfterLaunchFailure( + ctx, profile, current, request.SessionID, err, + ) + if fallbackErr != nil { + if errors.Is(fallbackErr, ErrNoEligibleCandidate) { + return DownstreamExecution{}, current, err + } + return DownstreamExecution{}, current, fallbackErr + } + if !shouldFallback || attempt+1 >= maxAttempts { + return DownstreamExecution{}, current, err + } + classified := classifiedLaunchFailure(err) + if classified != nil { + currentContinuation = continuationWithFailure(currentContinuation, classified.Error()) + } + if persistErr := c.persistContinuation(ctx, next, currentContinuation); persistErr != nil { + return DownstreamExecution{}, current, persistErr + } + current = next + } + return DownstreamExecution{}, current, ErrNoEligibleCandidate +} + +func priorACPSession( + persisted string, + initial RouteDecision, + current RouteDecision, +) string { + if current.ExecutionProfileID != initial.ExecutionProfileID { + return "" + } + return persisted +} + +func (c *Conductor) nextAfterLaunchFailure( + ctx context.Context, + profile Profile, + decision RouteDecision, + sessionID string, + launchErr error, +) (RouteDecision, bool, error) { + var classified *routingerr.Error + if !errors.As(launchErr, &classified) { + return RouteDecision{}, false, nil + } + if classified.Phase != "" && classified.Phase != routingerr.PhaseAuthCheck && + classified.Phase != routingerr.PhaseProcessStart && classified.Phase != routingerr.PhaseSessionInit { + return RouteDecision{}, false, nil + } + if !classified.FallbackAllowed { + return RouteDecision{}, false, nil + } + if c.engine.ActionFor(profile, decision.ExecutionProfileID, classified.Code) != ActionTryNext { + return RouteDecision{}, false, nil + } + next, err := c.engine.ApplyFailureContext( + ctx, sessionID, profile, decision.Generation, decision.ExecutionProfileID, classified, + ) + if err != nil { + return RouteDecision{}, true, err + } + return next, true, nil +} + +func classifiedLaunchFailure(err error) *routingerr.Error { + var classified *routingerr.Error + if errors.As(err, &classified) { + return classified + } + return nil +} + +func continuationWithFailure(current Continuation, reason string) Continuation { + current.FailureReason = bounded(reason) + return current +} + +func (c *Conductor) persistContinuation(ctx context.Context, decision RouteDecision, continuation Continuation) error { + if c.continuationPersistence == nil { + return nil + } + return c.continuationPersistence.SaveRouteContinuation(ctx, ContinuationRecord{ + SessionID: decision.SessionID, Generation: decision.Generation, + Continuation: continuation, UpdatedAt: time.Now().UTC(), + }) +} + +// Resume reuses a downstream ACP session only when its concrete profile owns +// the current logical route. A caller that changes candidates must call Launch +// with the new generation, which always creates a fresh downstream session. +func (c *Conductor) Resume(ctx context.Context, sessionID, prompt string) error { + c.mu.Lock() + execution, ok := c.active[sessionID] + c.mu.Unlock() + if !ok { + loader, supportsLoading := c.downstream.(DownstreamExecutionLoader) + if !supportsLoading { + return errors.New("dynamic conductor has no active downstream execution") + } + loaded, found, err := loader.LoadExecution(ctx, sessionID) + if err != nil { + return err + } + if !found { + return errors.New("dynamic conductor has no active downstream execution") + } + execution = loaded + c.mu.Lock() + c.active[sessionID] = execution + c.mu.Unlock() + } + return c.downstream.Resume(ctx, execution.ID, prompt) +} + +func (c *Conductor) Stop(ctx context.Context, sessionID, reason string) error { + c.mu.Lock() + execution, ok := c.active[sessionID] + delete(c.active, sessionID) + c.mu.Unlock() + if !ok { + return nil + } + return c.downstream.Stop(ctx, execution.ID, reason) +} + +func (c *Conductor) AcceptsGeneration(sessionID string, generation int64) bool { + state, ok := c.engine.State(sessionID) + return ok && state.Generation == generation +} + +func (c *Conductor) buildContinuation(ctx context.Context, input ContinuationInput) (Continuation, error) { + if c.continuationBuilder != nil { + return c.continuationBuilder(ctx, input) + } + return BuildBoundedContinuation(input), nil +} + +type ContinuationInput struct { + TaskDescription string + WorkflowStep string + UserMessages []string + Conversation string + ToolSummary string + RepositorySummary string + PlanSummary string + FailureReason string +} + +type Continuation struct { + TaskDescription string + WorkflowStep string + Conversation string + ToolSummary string + RepositorySummary string + PlanSummary string + FailureReason string +} + +const continuationFieldLimit = 4000 + +func BuildBoundedContinuation(input ContinuationInput) Continuation { + return Continuation{ + TaskDescription: bounded(input.TaskDescription), + WorkflowStep: bounded(input.WorkflowStep), + Conversation: bounded(strings.Join(input.UserMessages, "\n") + "\n" + input.Conversation), + ToolSummary: bounded(input.ToolSummary), + RepositorySummary: bounded(input.RepositorySummary), + PlanSummary: bounded(input.PlanSummary), + FailureReason: bounded(input.FailureReason), + } +} + +// ContinuationPrompt renders a bounded, provider-neutral handoff. The +// original prompt remains first so providers that do not understand the +// optional package still receive the user's request. +func ContinuationPrompt(prompt string, continuation Continuation) string { + fields := make([]string, 0, 7) + if continuation.TaskDescription != "" { + fields = append(fields, "Task: "+continuation.TaskDescription) + } + if continuation.WorkflowStep != "" { + fields = append(fields, "Workflow step: "+continuation.WorkflowStep) + } + if continuation.Conversation != "" { + fields = append(fields, "Durable conversation: "+continuation.Conversation) + } + if continuation.ToolSummary != "" { + fields = append(fields, "Tool summary: "+continuation.ToolSummary) + } + if continuation.RepositorySummary != "" { + fields = append(fields, "Repository summary: "+continuation.RepositorySummary) + } + if continuation.PlanSummary != "" { + fields = append(fields, "Plan summary: "+continuation.PlanSummary) + } + if continuation.FailureReason != "" { + fields = append(fields, "Predecessor failure: "+continuation.FailureReason) + } + if len(fields) == 0 { + return prompt + } + return strings.TrimSpace(prompt) + "\n\n[Kandev continuation package]\n" + strings.Join(fields, "\n") + + "\nVerify durable state before repeating any uncertain action." +} + +func bounded(value string) string { + value = strings.TrimSpace(value) + if len(value) <= continuationFieldLimit { + return value + } + return value[:continuationFieldLimit] +} diff --git a/apps/backend/internal/agent/runtime/dynamic/conductor_test.go b/apps/backend/internal/agent/runtime/dynamic/conductor_test.go new file mode 100644 index 0000000000..c28968a568 --- /dev/null +++ b/apps/backend/internal/agent/runtime/dynamic/conductor_test.go @@ -0,0 +1,231 @@ +package dynamic + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/agent/runtime/routingerr" +) + +type conductorTestProfileLoader struct { + profile Profile +} + +func (l conductorTestProfileLoader) LoadDynamicProfile(context.Context, string) (Profile, error) { + return l.profile, nil +} + +type conductorTestDownstream struct { + launches []DownstreamLaunch +} + +func (d *conductorTestDownstream) Launch(_ context.Context, launch DownstreamLaunch) (DownstreamExecution, error) { + d.launches = append(d.launches, launch) + if len(d.launches) == 1 { + return DownstreamExecution{}, &routingerr.Error{ + Code: routingerr.CodeProviderUnavailable, + FallbackAllowed: true, + } + } + return DownstreamExecution{ID: "execution-second"}, nil +} + +func (*conductorTestDownstream) Resume(context.Context, string, string) error { return nil } + +func (*conductorTestDownstream) Stop(context.Context, string, string) error { return nil } + +func TestConductorFallsBackAfterClassifiedLaunchFailure(t *testing.T) { + profile := Profile{ + ID: "dynamic-profile", + Candidates: []Candidate{ + { + ID: "candidate-first", + Enabled: true, + BindingKey: "first", + Rules: map[string]Action{string(routingerr.CodeProviderUnavailable): ActionTryNext}, + }, + {ID: "candidate-second", Enabled: true, BindingKey: "second"}, + }, + } + downstream := &conductorTestDownstream{} + conductor := NewConductor(NewEngine(), conductorTestProfileLoader{profile: profile}, downstream) + + result, err := conductor.Launch(context.Background(), ConductorLaunch{ + SessionID: "session-1", + LogicalProfileID: profile.ID, + Prompt: "hello", + PriorACPSession: "acp-first", + }) + if err != nil { + t.Fatalf("Launch: %v", err) + } + if result.Decision.ExecutionProfileID != "candidate-second" || result.Decision.Generation != 2 { + t.Fatalf("final decision = %#v", result.Decision) + } + if result.Execution.ID != "execution-second" { + t.Fatalf("execution = %#v", result.Execution) + } + if len(downstream.launches) != 2 { + t.Fatalf("launch count = %d, want 2", len(downstream.launches)) + } + if got := downstream.launches[0].Decision.ExecutionProfileID; got != "candidate-first" { + t.Fatalf("first decision profile = %q", got) + } + if got := downstream.launches[1].Decision.ExecutionProfileID; got != "candidate-second" { + t.Fatalf("fallback decision profile = %q", got) + } + if downstream.launches[1].Decision.Generation != 2 { + t.Fatalf("fallback generation = %d, want 2", downstream.launches[1].Decision.Generation) + } + if got := downstream.launches[0].PriorACPSession; got != "acp-first" { + t.Fatalf("first prior ACP session = %q, want acp-first", got) + } + if got := downstream.launches[1].PriorACPSession; got != "" { + t.Fatalf("fallback prior ACP session = %q, want empty", got) + } +} + +type restoringConductorTestDownstream struct { + loaded DownstreamExecution + resumed DownstreamExecution +} + +func (d *restoringConductorTestDownstream) Launch(context.Context, DownstreamLaunch) (DownstreamExecution, error) { + return d.loaded, nil +} + +func (d *restoringConductorTestDownstream) LoadExecution(context.Context, string) (DownstreamExecution, bool, error) { + return d.loaded, true, nil +} + +func (d *restoringConductorTestDownstream) Resume(_ context.Context, executionID, _ string) error { + d.resumed.ID = executionID + return nil +} + +func (*restoringConductorTestDownstream) Stop(context.Context, string, string) error { return nil } + +func TestConductorRestoresActiveExecutionBeforeResume(t *testing.T) { + downstream := &restoringConductorTestDownstream{loaded: DownstreamExecution{ID: "execution-restored"}} + conductor := NewConductor(NewEngine(), conductorTestProfileLoader{}, downstream) + if err := conductor.Resume(context.Background(), "session-restored", "continue"); err != nil { + t.Fatalf("Resume: %v", err) + } + if downstream.resumed.ID != "execution-restored" { + t.Fatalf("resumed execution = %q, want execution-restored", downstream.resumed.ID) + } +} + +func TestConductorDoesNotFallbackForUnclassifiedLaunchFailure(t *testing.T) { + profile := Profile{ + ID: "dynamic-profile", + Candidates: []Candidate{{ID: "candidate-first", Enabled: true}}, + } + failure := errors.New("workspace failed") + conductor := NewConductor( + NewEngine(), conductorTestProfileLoader{profile: profile}, + failingConductorTestDownstream{err: failure}, + ) + + _, err := conductor.Launch(context.Background(), ConductorLaunch{ + SessionID: "session-2", + LogicalProfileID: profile.ID, + }) + if !errors.Is(err, failure) { + t.Fatalf("Launch error = %v", err) + } +} + +func TestConductorWalksEveryCandidateAndPersistsFailureContext(t *testing.T) { + profile := Profile{ + ID: "dynamic-profile", + Candidates: []Candidate{ + {ID: "candidate-first", Enabled: true, BindingKey: "first", Rules: map[string]Action{ + string(routingerr.CodeProviderUnavailable): ActionTryNext, + }}, + {ID: "candidate-second", Enabled: true, BindingKey: "second", Rules: map[string]Action{ + string(routingerr.CodeProviderUnavailable): ActionTryNext, + }}, + {ID: "candidate-third", Enabled: true, BindingKey: "third"}, + }, + } + downstream := &multiFailureConductorTestDownstream{failures: 2} + store := &conductorContinuationStore{} + conductor := NewConductor( + NewEngine(), conductorTestProfileLoader{profile: profile}, downstream, + WithContinuationPersistence(store), + ) + + result, err := conductor.Launch(context.Background(), ConductorLaunch{ + SessionID: "session-three", LogicalProfileID: profile.ID, + Prompt: "continue the work", + Continuation: ContinuationInput{TaskDescription: "ship the change"}, + }) + if err != nil { + t.Fatalf("Launch: %v", err) + } + if result.Decision.ExecutionProfileID != "candidate-third" || result.Decision.Generation != 3 { + t.Fatalf("final decision = %#v", result.Decision) + } + if len(downstream.launches) != 3 { + t.Fatalf("launch count = %d, want 3", len(downstream.launches)) + } + if downstream.launches[1].Continuation.FailureReason == "" || downstream.launches[2].Continuation.FailureReason == "" { + t.Fatalf("fallback continuation did not retain failure context: %#v", downstream.launches) + } + if len(store.saved) != 2 || store.saved[0].Generation != 2 || store.saved[1].Generation != 3 { + t.Fatalf("persisted continuations = %#v, want generations 2 and 3", store.saved) + } +} + +func TestConductorDoesNotFallbackAfterPostStartLaunchFailure(t *testing.T) { + profile := Profile{ + ID: "dynamic-profile", + Candidates: []Candidate{ + {ID: "candidate-first", Enabled: true, Rules: map[string]Action{ + string(routingerr.CodeProviderUnavailable): ActionTryNext, + }}, + {ID: "candidate-second", Enabled: true}, + }, + } + failure := &routingerr.Error{Code: routingerr.CodeProviderUnavailable, Phase: routingerr.PhaseStreaming, FallbackAllowed: true} + conductor := NewConductor(NewEngine(), conductorTestProfileLoader{profile: profile}, failingConductorTestDownstream{err: failure}) + _, err := conductor.Launch(context.Background(), ConductorLaunch{SessionID: "session-post-start", LogicalProfileID: profile.ID}) + if !errors.Is(err, failure) { + t.Fatalf("Launch error = %v, want %v", err, failure) + } +} + +type multiFailureConductorTestDownstream struct { + launches []DownstreamLaunch + failures int +} + +func (d *multiFailureConductorTestDownstream) Launch(_ context.Context, launch DownstreamLaunch) (DownstreamExecution, error) { + d.launches = append(d.launches, launch) + if len(d.launches) <= d.failures { + return DownstreamExecution{}, &routingerr.Error{Code: routingerr.CodeProviderUnavailable, Phase: routingerr.PhaseProcessStart, FallbackAllowed: true} + } + return DownstreamExecution{ID: "execution-third"}, nil +} + +func (*multiFailureConductorTestDownstream) Resume(context.Context, string, string) error { return nil } +func (*multiFailureConductorTestDownstream) Stop(context.Context, string, string) error { return nil } + +type conductorContinuationStore struct{ saved []ContinuationRecord } + +func (s *conductorContinuationStore) SaveRouteContinuation(_ context.Context, record ContinuationRecord) error { + s.saved = append(s.saved, record) + return nil +} + +type failingConductorTestDownstream struct{ err error } + +func (d failingConductorTestDownstream) Launch(context.Context, DownstreamLaunch) (DownstreamExecution, error) { + return DownstreamExecution{}, d.err +} + +func (failingConductorTestDownstream) Resume(context.Context, string, string) error { return nil } + +func (failingConductorTestDownstream) Stop(context.Context, string, string) error { return nil } diff --git a/apps/backend/internal/agent/runtime/dynamic/engine.go b/apps/backend/internal/agent/runtime/dynamic/engine.go new file mode 100644 index 0000000000..dd4d1c622c --- /dev/null +++ b/apps/backend/internal/agent/runtime/dynamic/engine.go @@ -0,0 +1,707 @@ +package dynamic + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/kandev/kandev/internal/agent/runtime/routingerr" + "github.com/kandev/kandev/internal/agent/runtime/routingpolicy" +) + +type EngineOption func(*Engine) + +const routeStatusActionRequired = "action_required" + +func WithClock(now func() time.Time) EngineOption { + return func(engine *Engine) { + if now != nil { + engine.now = now + if engine.circuits != nil { + engine.circuits.now = now + } + } + } +} + +func WithCircuitRegistry(registry *CircuitRegistry) EngineOption { + return func(engine *Engine) { + if registry != nil { + engine.circuits = registry + } + } +} + +func WithPersistence(persistence Persistence) EngineOption { + return func(engine *Engine) { + engine.persistence = persistence + } +} + +type Engine struct { + mu sync.Mutex + now func() time.Time + circuits *CircuitRegistry + states map[string]RouteState + persistence Persistence + loader StateLoader + probes map[string]ProbeLease +} + +func NewEngine(options ...EngineOption) *Engine { + engine := &Engine{ + now: time.Now, + circuits: NewCircuitRegistry(), + states: make(map[string]RouteState), + probes: make(map[string]ProbeLease), + } + for _, option := range options { + option(engine) + } + return engine +} + +func (e *Engine) Circuits() *CircuitRegistry { return e.circuits } + +// ContinuationPersistence exposes the durable handoff seam without exposing +// the engine's other persistence responsibilities to the conductor. +func (e *Engine) ContinuationPersistence() ContinuationPersistence { + if e == nil || e.persistence == nil { + return nil + } + persistence, _ := e.persistence.(ContinuationPersistence) + return persistence +} + +// WithStateLoader enables restart recovery for sessions that are not already +// present in the process-local state map. +func WithStateLoader(loader StateLoader) EngineOption { + return func(engine *Engine) { engine.loader = loader } +} + +// Select claims one route generation. The mutex protects the in-memory CAS; +// callers that need restart durability provide the same state through the +// task repository integration before invoking the engine again. +func (e *Engine) Select(sessionID string, profile Profile, expectedGeneration int64, excludeProfileID string) (RouteDecision, error) { + return e.SelectContext(context.Background(), sessionID, profile, expectedGeneration, excludeProfileID) +} + +func (e *Engine) SelectContext(ctx context.Context, sessionID string, profile Profile, expectedGeneration int64, excludeProfileID string) (RouteDecision, error) { + return e.selectContext(ctx, sessionID, profile, expectedGeneration, excludeProfileID, "", "candidate_order") +} + +// SelectContextWithReason is used by generation-fenced manual route actions +// so the immutable attempt row records why a candidate was selected. +func (e *Engine) SelectContextWithReason( + ctx context.Context, + sessionID string, + profile Profile, + expectedGeneration int64, + excludeProfileID string, + reason string, +) (RouteDecision, error) { + return e.selectContext(ctx, sessionID, profile, expectedGeneration, excludeProfileID, "", reason) +} + +// SelectContextWithPreference retries the requested candidate when it remains +// eligible. A caller can still exclude that candidate for try-next behavior +// by using the ordinary SelectContext method. +func (e *Engine) SelectContextWithPreference( + ctx context.Context, + sessionID string, + profile Profile, + expectedGeneration int64, + excludeProfileID string, + preferredProfileID string, +) (RouteDecision, error) { + return e.selectContext(ctx, sessionID, profile, expectedGeneration, excludeProfileID, preferredProfileID, "candidate_order") +} + +func (e *Engine) selectContext( + ctx context.Context, + sessionID string, + profile Profile, + expectedGeneration int64, + excludeProfileID string, + preferredProfileID string, + reason string, +) (RouteDecision, error) { + e.mu.Lock() + defer e.mu.Unlock() + state, exists, err := e.loadStateLocked(ctx, sessionID) + if err != nil { + return RouteDecision{}, err + } + currentGeneration := int64(0) + if exists { + currentGeneration = state.Generation + } + if expectedGeneration != currentGeneration { + return RouteDecision{}, ErrStaleGeneration + } + generation := currentGeneration + 1 + now := e.now() + for _, candidate := range profile.Candidates { + if !e.candidateSelectable(candidate, sessionID, generation, excludeProfileID, preferredProfileID, now) { + continue + } + decision := RouteDecision{ + SessionID: sessionID, + LogicalProfileID: profile.ID, + ExecutionProfileID: candidate.ID, + Generation: generation, + ProfileVersion: profile.Version, + Reason: reason, + Status: "starting", + } + nextState := RouteState{ + SessionID: sessionID, + LogicalProfileID: profile.ID, + ExecutionProfileID: candidate.ID, + Generation: generation, + ProfileVersion: profile.Version, + Status: "starting", + UpdatedAt: now, + } + if err := e.claimAndPersist(ctx, expectedGeneration, decision, nextState); err != nil { + delete(e.states, sessionID) + return RouteDecision{}, err + } + e.states[sessionID] = nextState + return decision, nil + } + nextState := RouteState{ + SessionID: sessionID, + LogicalProfileID: profile.ID, + Generation: generation, + ProfileVersion: profile.Version, + Status: "waiting", + UpdatedAt: now, + } + if err := e.persistNoEligible(ctx, expectedGeneration, nextState); err != nil { + delete(e.states, sessionID) + return RouteDecision{}, err + } + e.states[sessionID] = nextState + return RouteDecision{}, &NoEligibleCandidateError{ + SessionID: sessionID, LogicalProfile: profile.ID, Generation: generation, + } +} + +func (e *Engine) loadStateLocked(ctx context.Context, sessionID string) (RouteState, bool, error) { + state, exists := e.states[sessionID] + if exists || e.loader == nil { + return state, exists, nil + } + loaded, err := e.loader.LoadRouteState(ctx, sessionID) + if err != nil { + return RouteState{}, false, err + } + if loaded == nil { + return RouteState{}, false, nil + } + e.states[sessionID] = *loaded + return *loaded, true, nil +} + +const probeLeaseDuration = 30 * time.Second + +func (e *Engine) candidateSelectable(candidate Candidate, sessionID string, generation int64, excludeProfileID, preferredProfileID string, now time.Time) bool { + if !candidate.Enabled || candidate.ID == excludeProfileID { + return false + } + if preferredProfileID != "" && candidate.ID != preferredProfileID { + return false + } + if candidate.BindingKey == "" || !e.circuits.IsOpen(candidate.BindingKey, now) { + return true + } + lease, ok := e.circuits.AcquireProbe(candidate.BindingKey, probeLeaseDuration) + if !ok { + return false + } + // The caller owns the generation fencing. The conductor releases this + // lease after the concrete launch result is known. + e.probes[probeKey(sessionID, generation, candidate.ID)] = lease + return true +} + +func probeKey(sessionID string, generation int64, candidateID string) string { + return sessionID + ":" + fmt.Sprint(generation) + ":" + candidateID +} + +func (e *Engine) persistNoEligible(ctx context.Context, expectedGeneration int64, state RouteState) error { + if e.persistence == nil { + return nil + } + claimer, ok := e.persistence.(GenerationClaimer) + if !ok { + return e.persistence.SaveRouteState(ctx, state) + } + claimed, err := claimer.ClaimRouteState(ctx, expectedGeneration, state) + if err != nil { + return err + } + if !claimed { + return ErrStaleGeneration + } + return nil +} + +func (e *Engine) persist(ctx context.Context, decision RouteDecision, state RouteState) error { + if e.persistence == nil { + return nil + } + if err := e.persistence.SaveRouteState(ctx, state); err != nil { + return err + } + return e.persistence.AppendRouteAttempt(ctx, RouteAttempt{ + SessionID: decision.SessionID, + LogicalProfileID: decision.LogicalProfileID, + ExecutionProfileID: decision.ExecutionProfileID, + Generation: decision.Generation, + ProfileVersion: decision.ProfileVersion, + Reason: decision.Reason, + CreatedAt: state.UpdatedAt, + }) +} + +func (e *Engine) claimAndPersist(ctx context.Context, expectedGeneration int64, decision RouteDecision, state RouteState) error { + if e.persistence == nil { + return nil + } + if recorder, ok := e.persistence.(DecisionRecorder); ok { + return recorder.RecordRouteDecision(ctx, decision, state) + } + if claimer, ok := e.persistence.(GenerationClaimer); ok { + claimed, err := claimer.ClaimRouteState(ctx, expectedGeneration, state) + if err != nil { + return err + } + if !claimed { + return ErrStaleGeneration + } + return e.persistence.AppendRouteAttempt(ctx, RouteAttempt{ + SessionID: decision.SessionID, LogicalProfileID: decision.LogicalProfileID, + ExecutionProfileID: decision.ExecutionProfileID, Generation: decision.Generation, + ProfileVersion: decision.ProfileVersion, Reason: decision.Reason, + CreatedAt: state.UpdatedAt, + }) + } + return e.persist(ctx, decision, state) +} + +func (e *Engine) State(sessionID string) (RouteState, bool) { + e.mu.Lock() + defer e.mu.Unlock() + state, ok := e.states[sessionID] + return state, ok +} + +// LoadState returns the current route state and hydrates it from the durable +// loader when this process has not handled the session before. +func (e *Engine) LoadState(ctx context.Context, sessionID string) (RouteState, bool, error) { + e.mu.Lock() + defer e.mu.Unlock() + return e.loadStateLocked(ctx, sessionID) +} + +func (e *Engine) ActionFor(profile Profile, candidateID string, code routingerr.Code) Action { + for _, candidate := range profile.Candidates { + if candidate.ID != candidateID { + continue + } + return actionForCandidate(candidate, code) + } + return ActionStop +} + +func actionForCandidate(candidate Candidate, code routingerr.Code) Action { + if candidate.Policies.Version == 0 { + return actionForRules(candidate.Rules, code) + } + class := routingerr.ClassForCode(code) + policy, ok := candidate.Policies.PolicyFor(class) + if !ok { + return ActionStop + } + if policy.Retry.Enabled { + return ActionRetrySame + } + if policy.OnExhausted == routingpolicy.OutcomeSkip { + return ActionTryNext + } + return ActionStop +} + +// ApplyFailure translates a classified error into the next generation. A +// retry keeps the same candidate; try_next excludes it for this decision only. +func (e *Engine) ApplyFailure( + sessionID string, + profile Profile, + expectedGeneration int64, + currentCandidateID string, + failure *routingerr.Error, +) (RouteDecision, error) { + return e.ApplyFailureContext(context.Background(), sessionID, profile, expectedGeneration, currentCandidateID, failure) +} + +// ApplyFailureContext applies a classified failure and persists the next +// route decision using the supplied context. +func (e *Engine) ApplyFailureContext( + ctx context.Context, + sessionID string, + profile Profile, + expectedGeneration int64, + currentCandidateID string, + failure *routingerr.Error, +) (RouteDecision, error) { + if failure == nil { + return RouteDecision{}, ErrNoEligibleCandidate + } + e.openCircuitForFailure(profile, currentCandidateID, failure) + e.releaseProbeForFailure(sessionID, expectedGeneration, currentCandidateID) + if candidate, ok := candidateByID(profile, currentCandidateID); ok && candidate.Policies.Version != 0 { + return e.applyPolicyFailure(ctx, sessionID, profile, expectedGeneration, currentCandidateID, candidate, failure) + } + action := e.ActionFor(profile, currentCandidateID, failure.Code) + switch action { + case ActionRetrySame: + return e.selectContext(ctx, sessionID, profile, expectedGeneration, "", currentCandidateID, "retry") + case ActionTryNext: + return e.selectContext(ctx, sessionID, profile, expectedGeneration, currentCandidateID, "", "try_next") + default: + return RouteDecision{}, ErrNoEligibleCandidate + } +} + +func candidateByID(profile Profile, candidateID string) (Candidate, bool) { + for _, candidate := range profile.Candidates { + if candidate.ID == candidateID { + return candidate, true + } + } + return Candidate{}, false +} + +func (e *Engine) applyPolicyFailure( + ctx context.Context, + sessionID string, + profile Profile, + expectedGeneration int64, + currentCandidateID string, + candidate Candidate, + failure *routingerr.Error, +) (RouteDecision, error) { + state, exists, policyState, evaluation, now, err := e.preparePolicyFailure( + ctx, sessionID, expectedGeneration, candidate, failure, + ) + if err != nil { + return RouteDecision{}, err + } + + if evaluation.Kind == routingpolicy.DecisionSkip { + return e.selectContext(ctx, sessionID, profile, expectedGeneration, currentCandidateID, "", "policy_skip") + } + nextState := state + if !exists { + nextState = RouteState{ + SessionID: sessionID, + LogicalProfileID: profile.ID, + ExecutionProfileID: currentCandidateID, + Generation: expectedGeneration, + ProfileVersion: profile.Version, + } + } + nextState.Status = string(evaluation.Kind) + nextState.PolicyStateJSON = string(mustJSON(policyState)) + nextState.UpdatedAt = now + if evaluation.Kind == routingpolicy.DecisionStop { + nextState.Status = routeStatusActionRequired + } + if err := e.persistSameGeneration(ctx, expectedGeneration, nextState); err != nil { + return RouteDecision{}, err + } + e.mu.Lock() + e.states[sessionID] = nextState + e.mu.Unlock() + var deadline *time.Time + if !evaluation.Deadline.IsZero() { + value := evaluation.Deadline + deadline = &value + } + decision := RouteDecision{ + SessionID: sessionID, LogicalProfileID: profile.ID, + ExecutionProfileID: currentCandidateID, Generation: expectedGeneration, + ProfileVersion: profile.Version, Reason: string(evaluation.Kind), + Status: nextState.Status, Deadline: deadline, + ErrorCode: evaluation.Code, ErrorClass: evaluation.Class, + CatalogueVersion: evaluation.CatalogueVersion, + RetryOrdinal: evaluation.RetryOrdinal, PendingOutcome: evaluation.PendingOutcome, + } + if evaluation.Kind == routingpolicy.DecisionStop { + return decision, ErrNoEligibleCandidate + } + return decision, fmt.Errorf("%w: %s", ErrRecoveryPending, evaluation.Kind) +} + +func (e *Engine) preparePolicyFailure( + ctx context.Context, + sessionID string, + expectedGeneration int64, + candidate Candidate, + failure *routingerr.Error, +) (RouteState, bool, PolicyState, routingpolicy.Evaluation, time.Time, error) { + state, exists, err := e.stateForFailure(ctx, sessionID) + if err != nil { + return RouteState{}, false, PolicyState{}, routingpolicy.Evaluation{}, time.Time{}, err + } + if exists && state.Generation != expectedGeneration { + return RouteState{}, false, PolicyState{}, routingpolicy.Evaluation{}, time.Time{}, ErrStaleGeneration + } + policyState := PolicyState{} + if exists && state.PolicyStateJSON != "" { + if err := json.Unmarshal([]byte(state.PolicyStateJSON), &policyState); err != nil { + return RouteState{}, false, PolicyState{}, routingpolicy.Evaluation{}, time.Time{}, fmt.Errorf("decode dynamic policy state: %w", err) + } + } + now := e.now() + failureClass := failure.Class + if failureClass == "" { + failureClass = routingerr.ClassForCode(failure.Code) + } + resetWaitUsed := policyState.ResetWaitUsed && policyState.FailureClass == failureClass + if policyState.ResetWaitClasses != nil { + resetWaitUsed = policyState.ResetWaitClasses[failureClass] + } + evaluation := routingpolicy.Evaluate(candidate.Policies, routingpolicy.EvaluationInput{ + Failure: failure, Now: now, RetryOrdinal: policyState.RetryOrdinal, + ResetWaitUsed: resetWaitUsed, EffectSafe: failure.FallbackAllowed, + }) + policyJSON, err := json.Marshal(candidate.Policies) + if err != nil { + return RouteState{}, false, PolicyState{}, routingpolicy.Evaluation{}, time.Time{}, fmt.Errorf("encode dynamic policy snapshot: %w", err) + } + policyState.FailureCode = evaluation.Code + policyState.FailureClass = evaluation.Class + policyState.CatalogueVersion = evaluation.CatalogueVersion + policyState.PolicyJSON = string(policyJSON) + policyState.RetryOrdinal = evaluation.RetryOrdinal + if policyState.ResetWaitClasses == nil { + policyState.ResetWaitClasses = make(map[routingerr.Class]bool) + } + if evaluation.Kind == routingpolicy.DecisionWaitForReset { + policyState.ResetWaitClasses[evaluation.Class] = true + } + policyState.ResetWaitUsed = policyState.ResetWaitClasses[evaluation.Class] + policyState.PendingOutcome = evaluation.PendingOutcome + if evaluation.Deadline.IsZero() { + policyState.Deadline = nil + } else { + deadline := evaluation.Deadline + policyState.Deadline = &deadline + } + return state, exists, policyState, evaluation, now, nil +} + +func (e *Engine) stateForFailure(ctx context.Context, sessionID string) (RouteState, bool, error) { + e.mu.Lock() + defer e.mu.Unlock() + return e.loadStateLocked(ctx, sessionID) +} + +func (e *Engine) persistSameGeneration(ctx context.Context, expectedGeneration int64, state RouteState) error { + if e.persistence == nil { + return nil + } + if claimer, ok := e.persistence.(GenerationClaimer); ok { + claimed, err := claimer.ClaimRouteState(ctx, expectedGeneration, state) + if err != nil { + return err + } + if !claimed { + return ErrStaleGeneration + } + return nil + } + return e.persistence.SaveRouteState(ctx, state) +} + +func mustJSON(value PolicyState) []byte { + payload, err := json.Marshal(value) + if err != nil { + return []byte(`{}`) + } + return payload +} + +// ResumePending advances a due retry/wait state to retrying. The caller still +// owns the concrete launch and must pass the returned generation through its +// normal launch fence. +func (e *Engine) ResumePending(ctx context.Context, sessionID string, expectedGeneration int64) (RouteDecision, error) { + return e.resumePending(ctx, sessionID, expectedGeneration, false) +} + +// ResumePendingNow is the generation-fenced manual "Retry now" operation. +// It bypasses the deadline but never bypasses the durable generation owner. +func (e *Engine) ResumePendingNow(ctx context.Context, sessionID string, expectedGeneration int64) (RouteDecision, error) { + return e.resumePending(ctx, sessionID, expectedGeneration, true) +} + +// CancelPending transitions a pending wait to manual recovery without +// advancing the route generation. Stop and cancel-wait both use this durable +// state transition; the caller chooses the reason shown to the user. +func (e *Engine) CancelPending( + ctx context.Context, + sessionID string, + expectedGeneration int64, + reason string, +) (RouteDecision, error) { + state, exists, err := e.stateForFailure(ctx, sessionID) + if err != nil { + return RouteDecision{}, err + } + if !exists { + return RouteDecision{}, ErrRouteStateNotFound + } + if state.Generation != expectedGeneration { + return RouteDecision{}, ErrStaleGeneration + } + if state.Status != string(routingpolicy.DecisionRetry) && + state.Status != string(routingpolicy.DecisionWaitForReset) && + state.Status != routeStatusActionRequired { + return RouteDecision{}, ErrRecoveryPending + } + var policyState PolicyState + if state.PolicyStateJSON != "" { + if err := json.Unmarshal([]byte(state.PolicyStateJSON), &policyState); err != nil { + return RouteDecision{}, err + } + } + policyState.PendingOutcome = routingpolicy.OutcomeStop + state.PolicyStateJSON = string(mustJSON(policyState)) + state.Status = routeStatusActionRequired + state.UpdatedAt = e.now() + if err := e.persistSameGeneration(ctx, expectedGeneration, state); err != nil { + return RouteDecision{}, err + } + e.mu.Lock() + e.states[sessionID] = state + e.mu.Unlock() + return RouteDecision{ + SessionID: sessionID, LogicalProfileID: state.LogicalProfileID, + ExecutionProfileID: state.ExecutionProfileID, Generation: state.Generation, + ProfileVersion: state.ProfileVersion, Reason: reason, Status: state.Status, + ErrorCode: policyState.FailureCode, ErrorClass: policyState.FailureClass, + CatalogueVersion: policyState.CatalogueVersion, RetryOrdinal: policyState.RetryOrdinal, + PendingOutcome: policyState.PendingOutcome, Deadline: policyState.Deadline, + }, nil +} + +func (e *Engine) resumePending( + ctx context.Context, + sessionID string, + expectedGeneration int64, + force bool, +) (RouteDecision, error) { + state, exists, err := e.stateForFailure(ctx, sessionID) + if err != nil { + return RouteDecision{}, err + } + if !exists { + return RouteDecision{}, ErrRouteStateNotFound + } + if state.Generation != expectedGeneration { + return RouteDecision{}, ErrStaleGeneration + } + if force && state.Status == "retrying" { + return RouteDecision{}, ErrRecoveryPending + } + if !force && state.Status != string(routingpolicy.DecisionRetry) && state.Status != string(routingpolicy.DecisionWaitForReset) { + return RouteDecision{}, ErrRecoveryPending + } + var policyState PolicyState + if state.PolicyStateJSON != "" { + if err := json.Unmarshal([]byte(state.PolicyStateJSON), &policyState); err != nil { + return RouteDecision{}, err + } + } + now := e.now() + if !force && policyState.Deadline != nil && now.Before(*policyState.Deadline) { + return RouteDecision{}, ErrRecoveryNotDue + } + state.Status = "retrying" + state.UpdatedAt = now + if err := e.persistSameGeneration(ctx, expectedGeneration, state); err != nil { + return RouteDecision{}, err + } + e.mu.Lock() + e.states[sessionID] = state + e.mu.Unlock() + return RouteDecision{ + SessionID: sessionID, LogicalProfileID: state.LogicalProfileID, + ExecutionProfileID: state.ExecutionProfileID, Generation: state.Generation, + ProfileVersion: state.ProfileVersion, Reason: "retry_due", Status: state.Status, + ErrorCode: policyState.FailureCode, ErrorClass: policyState.FailureClass, + CatalogueVersion: policyState.CatalogueVersion, + RetryOrdinal: policyState.RetryOrdinal, PendingOutcome: policyState.PendingOutcome, + }, nil +} + +// ReleaseProbe closes a successful half-open resource probe or applies the +// standard failure backoff. It is safe to call when the selected candidate did +// not hold a probe lease. +func (e *Engine) ReleaseProbe(decision RouteDecision, success bool) { + if e == nil || e.circuits == nil { + return + } + e.mu.Lock() + key := probeKey(decision.SessionID, decision.Generation, decision.ExecutionProfileID) + lease, ok := e.probes[key] + if ok { + delete(e.probes, key) + } + e.mu.Unlock() + if ok { + e.circuits.ReleaseProbe(lease, success, circuitBackoff) + } +} + +func (e *Engine) releaseProbeForFailure(sessionID string, generation int64, candidateID string) { + e.ReleaseProbe(RouteDecision{SessionID: sessionID, Generation: generation, ExecutionProfileID: candidateID}, false) +} + +func (e *Engine) openCircuitForFailure(profile Profile, candidateID string, failure *routingerr.Error) { + if failure == nil || e.circuits == nil || !qualifiesForCircuit(failure.Code) { + return + } + for _, candidate := range profile.Candidates { + if candidate.ID != candidateID || candidate.BindingKey == "" { + continue + } + until := e.now().Add(circuitBackoff) + if failure.ResetHint != nil && failure.ResetHint.After(until) { + until = *failure.ResetHint + } + e.circuits.Open(candidate.BindingKey, until, failure.Code) + return + } +} + +const circuitBackoff = time.Minute + +func qualifiesForCircuit(code routingerr.Code) bool { + switch code { + case routingerr.CodeAuthRequired, routingerr.CodeMissingCredentials, + routingerr.CodeSubscriptionRequired, routingerr.CodeProviderNotConfigured, + routingerr.CodeRateLimited, routingerr.CodeQuotaLimited, + routingerr.CodeNetworkUnavailable, routingerr.CodeModelCapacity, + routingerr.CodeProviderUnavailable, routingerr.CodeProviderOverloaded, + routingerr.CodeModelUnavailable: + return true + default: + return false + } +} diff --git a/apps/backend/internal/agent/runtime/dynamic/engine_test.go b/apps/backend/internal/agent/runtime/dynamic/engine_test.go new file mode 100644 index 0000000000..644b8b1d22 --- /dev/null +++ b/apps/backend/internal/agent/runtime/dynamic/engine_test.go @@ -0,0 +1,344 @@ +package dynamic + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/kandev/kandev/internal/agent/runtime/routingerr" + "github.com/kandev/kandev/internal/agent/runtime/routingpolicy" +) + +func TestEngineSelectsFixedOrderAndFencesGenerations(t *testing.T) { + now := time.Unix(100, 0) + engine := NewEngine(WithClock(func() time.Time { return now })) + profile := Profile{ + ID: "dynamic-1", + Version: 4, + Candidates: []Candidate{ + {ID: "disabled", Enabled: false, BindingKey: "disabled"}, + {ID: "open", Enabled: true, BindingKey: "open"}, + {ID: "fallback", Enabled: true, BindingKey: "fallback"}, + }, + } + engine.Circuits().Open("open", now.Add(time.Hour), routingerr.CodeProviderUnavailable) + + decision, err := engine.Select("session-1", profile, 0, "") + if err != nil { + t.Fatalf("Select: %v", err) + } + if decision.ExecutionProfileID != "fallback" || decision.Generation != 1 || decision.ProfileVersion != 4 { + t.Fatalf("decision = %#v", decision) + } + if _, err := engine.Select("session-1", profile, 0, ""); !errors.Is(err, ErrStaleGeneration) { + t.Fatalf("stale select error = %v, want %v", err, ErrStaleGeneration) + } + engine.Circuits().Open("open", now.Add(-time.Second), routingerr.CodeProviderUnavailable) + decision, err = engine.Select("session-1", profile, 1, "fallback") + if err != nil { + t.Fatalf("Try next select: %v", err) + } + if decision.ExecutionProfileID != "open" || decision.Generation != 2 { + t.Fatalf("try next decision = %#v", decision) + } +} + +func TestEnginePreferenceKeepsRetryOnCurrentCandidate(t *testing.T) { + engine := NewEngine() + profile := Profile{ + ID: "dynamic-1", + Candidates: []Candidate{ + {ID: "first", Enabled: true}, + {ID: "second", Enabled: true}, + }, + } + decision, err := engine.SelectContextWithPreference( + context.Background(), "session-1", profile, 0, "", "second", + ) + if err != nil { + t.Fatalf("SelectContextWithPreference: %v", err) + } + if decision.ExecutionProfileID != "second" { + t.Fatalf("preferred decision = %#v", decision) + } +} + +func TestEngineAppliesCandidateErrorActions(t *testing.T) { + profile := Profile{ + ID: "dynamic-1", + Version: 1, + Candidates: []Candidate{ + {ID: "first", Enabled: true, BindingKey: "first", Rules: map[string]Action{ + string(routingerr.CodeQuotaLimited): ActionTryNext, + string(routingerr.CodeRateLimited): ActionRetrySame, + string(routingerr.CodeTask): ActionStop, + }}, + {ID: "second", Enabled: true, BindingKey: "second"}, + }, + } + engine := NewEngine() + if got := engine.ActionFor(profile, "first", routingerr.CodeQuotaLimited); got != ActionTryNext { + t.Fatalf("quota action = %q, want %q", got, ActionTryNext) + } + if got := engine.ActionFor(profile, "first", routingerr.CodeRateLimited); got != ActionRetrySame { + t.Fatalf("rate action = %q, want %q", got, ActionRetrySame) + } + if got := engine.ActionFor(profile, "first", routingerr.CodeTask); got != ActionStop { + t.Fatalf("task action = %q, want %q", got, ActionStop) + } + if got := engine.ActionFor(profile, "second", routingerr.CodeQuotaLimited); got != ActionStop { + t.Fatalf("unconfigured action = %q, want %q", got, ActionStop) + } +} + +func TestEngineRetrySameKeepsTheFailedCandidate(t *testing.T) { + profile := Profile{ + ID: "dynamic-1", Version: 1, + Candidates: []Candidate{ + {ID: "first", Enabled: true, Rules: map[string]Action{ + string(routingerr.CodeRateLimited): ActionRetrySame, + }}, + {ID: "second", Enabled: true}, + }, + } + engine := NewEngine() + initial, err := engine.Select("session-1", profile, 0, "") + if err != nil { + t.Fatalf("initial Select: %v", err) + } + initialDecision, err := engine.ApplyFailure( + "session-1", profile, initial.Generation, initial.ExecutionProfileID, + &routingerr.Error{Code: routingerr.CodeRateLimited}, + ) + if err != nil { + t.Fatalf("ApplyFailure: %v", err) + } + if initialDecision.ExecutionProfileID != "first" || initialDecision.Generation != 2 { + t.Fatalf("retry decision = %#v, want first at generation 2", initialDecision) + } +} + +func TestEnginePersistsPolicyWaitAndResumesWithGenerationFence(t *testing.T) { + now := time.Unix(100, 0).UTC() + engine := NewEngine(WithClock(func() time.Time { return now })) + document := routingpolicy.DefaultDocument() + document.Transient.Retry = routingpolicy.RetryPolicy{ + Enabled: true, MaxRetries: 2, InitialIntervalSeconds: 5, + } + profile := Profile{ + ID: "dynamic-policy", Version: 1, + Candidates: []Candidate{ + {ID: "first", Enabled: true, Policies: document}, + {ID: "second", Enabled: true}, + }, + } + initial, err := engine.Select("policy-session", profile, 0, "") + if err != nil { + t.Fatalf("initial Select: %v", err) + } + decision, err := engine.ApplyFailure( + "policy-session", profile, initial.Generation, initial.ExecutionProfileID, + &routingerr.Error{ + Code: routingerr.CodeRateLimited, Class: routingerr.ClassTransient, + FallbackAllowed: true, + }, + ) + if !errors.Is(err, ErrRecoveryPending) { + t.Fatalf("ApplyFailure error = %v, want pending recovery", err) + } + if decision.Status != "retry_wait" || decision.Generation != initial.Generation { + t.Fatalf("pending decision = %#v", decision) + } + state, ok := engine.State("policy-session") + if !ok || state.PolicyStateJSON == "" { + t.Fatalf("policy state was not persisted: %#v, ok=%v", state, ok) + } + now = now.Add(5 * time.Second) + due, err := engine.ResumePending(context.Background(), "policy-session", initial.Generation) + if err != nil { + t.Fatalf("ResumePending: %v", err) + } + if due.Status != "retrying" || due.ExecutionProfileID != "first" { + t.Fatalf("due decision = %#v", due) + } + if _, err := engine.ResumePending(context.Background(), "policy-session", initial.Generation-1); !errors.Is(err, ErrStaleGeneration) { + t.Fatalf("stale ResumePending error = %v", err) + } +} + +func TestEngineManualRetryNowBypassesPolicyDeadlineWithoutAdvancingGeneration(t *testing.T) { + now := time.Unix(300, 0).UTC() + engine := NewEngine(WithClock(func() time.Time { return now })) + document := routingpolicy.DefaultDocument() + document.Transient.Retry = routingpolicy.RetryPolicy{Enabled: true, MaxRetries: 1, InitialIntervalSeconds: 60} + profile := Profile{ID: "manual-policy", Version: 1, Candidates: []Candidate{ + {ID: "first", Enabled: true, Policies: document}, + {ID: "second", Enabled: true}, + }} + initial, err := engine.Select("manual-session", profile, 0, "") + if err != nil { + t.Fatalf("initial Select: %v", err) + } + pending, err := engine.ApplyFailure( + "manual-session", profile, initial.Generation, initial.ExecutionProfileID, + &routingerr.Error{Code: routingerr.CodeRateLimited, Class: routingerr.ClassTransient, FallbackAllowed: true}, + ) + if !errors.Is(err, ErrRecoveryPending) || pending.Status != "retry_wait" { + t.Fatalf("pending decision = %#v, error = %v", pending, err) + } + manual, err := engine.ResumePendingNow(context.Background(), "manual-session", initial.Generation) + if err != nil { + t.Fatalf("ResumePendingNow: %v", err) + } + if manual.Generation != initial.Generation || manual.ExecutionProfileID != "first" || manual.Status != "retrying" { + t.Fatalf("manual decision = %#v", manual) + } +} + +func TestEngineManualSkipRecordsItsReason(t *testing.T) { + engine := NewEngine() + profile := Profile{ID: "manual-skip", Version: 1, Candidates: []Candidate{ + {ID: "first", Enabled: true}, + {ID: "second", Enabled: true}, + }} + initial, err := engine.Select("skip-session", profile, 0, "") + if err != nil { + t.Fatalf("initial Select: %v", err) + } + next, err := engine.SelectContextWithReason( + context.Background(), "skip-session", profile, initial.Generation, initial.ExecutionProfileID, "manual_skip", + ) + if err != nil { + t.Fatalf("manual skip: %v", err) + } + if next.ExecutionProfileID != "second" || next.Reason != "manual_skip" { + t.Fatalf("manual skip decision = %#v", next) + } +} + +func TestEngineCancelPendingStopsWithoutAdvancingGeneration(t *testing.T) { + engine := NewEngine() + document := routingpolicy.DefaultDocument() + document.Transient.Retry = routingpolicy.RetryPolicy{Enabled: true, MaxRetries: 1, InitialIntervalSeconds: 5} + profile := Profile{ID: "cancel-policy", Version: 1, Candidates: []Candidate{ + {ID: "first", Enabled: true, Policies: document}, + }} + initial, err := engine.Select("cancel-session", profile, 0, "") + if err != nil { + t.Fatalf("initial Select: %v", err) + } + pending, err := engine.ApplyFailure( + "cancel-session", profile, initial.Generation, initial.ExecutionProfileID, + &routingerr.Error{Code: routingerr.CodeRateLimited, Class: routingerr.ClassTransient, FallbackAllowed: true}, + ) + if !errors.Is(err, ErrRecoveryPending) || pending.Status != "retry_wait" { + t.Fatalf("pending decision = %#v, error = %v", pending, err) + } + stopped, err := engine.CancelPending(context.Background(), "cancel-session", initial.Generation, "manual_stop") + if err != nil { + t.Fatalf("CancelPending: %v", err) + } + if stopped.Status != "action_required" || stopped.Generation != initial.Generation || stopped.Reason != "manual_stop" { + t.Fatalf("stopped decision = %#v", stopped) + } +} + +func TestBindingFingerprinterUsesOpaqueStableHMACKeys(t *testing.T) { + key := []byte("installation-secret") + fingerprinter := NewBindingFingerprinter(key) + descriptor := CredentialBindingDescriptor{ + Version: 1, + AgentFamilyID: "claude-acp", + AuthenticationMethod: "subscription", + CredentialSourceKind: "credential_file", + CredentialLocator: "home:claude", + ExecutorNamespace: "local", + AuthorizationScope: "messages", + WorkspaceScope: "workspace-1", + } + a := fingerprinter.Fingerprint(descriptor) + b := fingerprinter.Fingerprint(descriptor) + if a == "" || a != b || len(a) != 64 { + t.Fatalf("fingerprints = %q and %q", a, b) + } + changed := descriptor + changed.WorkspaceScope = "workspace-2" + if a == fingerprinter.Fingerprint(changed) { + t.Fatal("different binding scopes shared a fingerprint") + } + if containsSensitive(a, "installation-secret") || containsSensitive(a, "home:claude") { + t.Fatal("fingerprint exposed descriptor material") + } + if fallback := fingerprinter.FallbackFingerprint("profile-1"); fallback != "profile:profile-1" { + t.Fatalf("fallback fingerprint = %q", fallback) + } +} + +func TestCredentialBindingResolverCanonicalizesAndFallsBackToProfileScope(t *testing.T) { + resolver := NewCredentialBindingResolver([]byte("installation-secret")) + base := CredentialBindingDescriptor{ + Version: 1, AgentFamilyID: "Claude-ACP", AuthenticationMethod: "Subscription", + CredentialSourceKind: "credential_file", CredentialLocator: " home:claude ", + } + if resolver.Fingerprint(base) != resolver.Fingerprint(CredentialBindingDescriptor{ + Version: 1, AgentFamilyID: "claude-acp", AuthenticationMethod: "subscription", + CredentialSourceKind: "credential_file", CredentialLocator: "home:claude", + }) { + t.Fatal("canonical descriptors produced different fingerprints") + } + if got := resolver.Resolve(CredentialBindingDescriptor{}, "profile-1"); got != "profile:profile-1" { + t.Fatalf("fallback binding = %q", got) + } +} + +func TestCircuitProbeLeaseIsExclusive(t *testing.T) { + now := time.Unix(200, 0) + registry := NewCircuitRegistry(WithCircuitClock(func() time.Time { return now })) + registry.Open("provider:claude", now.Add(-time.Second), routingerr.CodeQuotaLimited) + lease, ok := registry.AcquireProbe("provider:claude", time.Minute) + if !ok || lease.Key != "provider:claude" { + t.Fatalf("first probe lease = %#v, ok=%v", lease, ok) + } + if _, ok := registry.AcquireProbe("provider:claude", time.Minute); ok { + t.Fatal("second worker acquired the same half-open probe") + } + now = now.Add(2 * time.Minute) + lease2, ok := registry.AcquireProbe("provider:claude", time.Minute) + if !ok { + t.Fatal("expired probe lease was not replaced") + } + registry.ReleaseProbe(lease, true, time.Second) + if _, ok := registry.AcquireProbe("provider:claude", time.Minute); ok { + t.Fatal("stale probe lease changed the current half-open circuit") + } + registry.ReleaseProbe(lease2, false, time.Second) + now = now.Add(2 * time.Second) + if _, ok := registry.AcquireProbe("provider:claude", time.Minute); !ok { + t.Fatal("probe lease was not released after failure backoff") + } +} + +func TestEngineUsesExclusiveProbeForExpiredCircuit(t *testing.T) { + now := time.Unix(500, 0) + engine := NewEngine(WithClock(func() time.Time { return now })) + engine.Circuits().Open("shared-binding", now.Add(-time.Second), routingerr.CodeQuotaLimited) + profile := Profile{ID: "dynamic-probe", Candidates: []Candidate{ + {ID: "first", Enabled: true, BindingKey: "shared-binding"}, + {ID: "second", Enabled: true, BindingKey: "other"}, + }} + first, err := engine.Select("probe-session-1", profile, 0, "") + if err != nil || first.ExecutionProfileID != "first" { + t.Fatalf("first selection = %#v, %v; want probe candidate", first, err) + } + second, err := engine.Select("probe-session-2", profile, 0, "") + if err != nil || second.ExecutionProfileID != "second" { + t.Fatalf("second selection = %#v, %v; want other candidate while probe is held", second, err) + } + engine.ReleaseProbe(first, true) +} + +func containsSensitive(value, sensitive string) bool { + return sensitive != "" && strings.Contains(value, sensitive) +} diff --git a/apps/backend/internal/agent/runtime/dynamic/fingerprint.go b/apps/backend/internal/agent/runtime/dynamic/fingerprint.go new file mode 100644 index 0000000000..378b62bdaf --- /dev/null +++ b/apps/backend/internal/agent/runtime/dynamic/fingerprint.go @@ -0,0 +1,123 @@ +package dynamic + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" +) + +// CredentialBindingDescriptor contains only non-secret binding identity. The +// fingerprint is opaque and safe to persist in route health rows. +type CredentialBindingDescriptor struct { + Version int `json:"version"` + AgentFamilyID string `json:"agent_family_id"` + AuthenticationMethod string `json:"authentication_method"` + CredentialSourceKind string `json:"credential_source_kind"` + CredentialLocator string `json:"credential_locator"` + ExecutorNamespace string `json:"executor_namespace"` + AuthorizationScope string `json:"authorization_scope"` + WorkspaceScope string `json:"workspace_scope"` +} + +type BindingFingerprinter struct { + key []byte +} + +// CredentialBindingResolver canonicalizes adapter descriptors before deriving +// an opaque installation-scoped fingerprint. It is intentionally small so +// concrete launch adapters can provide non-secret identity without importing +// the routing engine's storage or circuit types. +type CredentialBindingResolver struct { + fingerprinter *BindingFingerprinter +} + +type InstallationKeyStore interface { + LoadOrCreate(context.Context) ([]byte, error) +} + +func NewPersistentBindingFingerprinter(ctx context.Context, store InstallationKeyStore) (*BindingFingerprinter, error) { + key, err := store.LoadOrCreate(ctx) + if err != nil { + return nil, err + } + if len(key) == 0 { + return nil, fmt.Errorf("installation key store returned an empty key") + } + return NewBindingFingerprinter(key), nil +} + +func NewPersistentCredentialBindingResolver(ctx context.Context, store InstallationKeyStore) (*CredentialBindingResolver, error) { + fingerprinter, err := NewPersistentBindingFingerprinter(ctx, store) + if err != nil { + return nil, err + } + return &CredentialBindingResolver{fingerprinter: fingerprinter}, nil +} + +func NewBindingFingerprinter(installationKey []byte) *BindingFingerprinter { + key := make([]byte, len(installationKey)) + copy(key, installationKey) + return &BindingFingerprinter{key: key} +} + +func NewCredentialBindingResolver(installationKey []byte) *CredentialBindingResolver { + return &CredentialBindingResolver{fingerprinter: NewBindingFingerprinter(installationKey)} +} + +// Fingerprint returns the same key for semantically equivalent descriptors. +// Empty binding identity is rejected so callers can use a profile-scoped +// fallback instead of accidentally sharing health across unknown accounts. +func (r *CredentialBindingResolver) Fingerprint(descriptor CredentialBindingDescriptor) string { + if r == nil || r.fingerprinter == nil { + return "" + } + descriptor = canonicalDescriptor(descriptor) + if descriptor.AgentFamilyID == "" || descriptor.AuthenticationMethod == "" || + descriptor.CredentialSourceKind == "" { + return "" + } + return r.fingerprinter.Fingerprint(descriptor) +} + +// Resolve chooses a conservative profile scope when an adapter cannot prove +// a usable credential binding. The profile ID is never part of a shared +// provider/account fingerprint, only this isolated fallback key. +func (r *CredentialBindingResolver) Resolve(descriptor CredentialBindingDescriptor, profileID string) string { + if r == nil || r.fingerprinter == nil { + return "profile:" + profileID + } + if fingerprint := r.Fingerprint(descriptor); fingerprint != "" { + return fingerprint + } + return r.fingerprinter.FallbackFingerprint(profileID) +} + +func (f *BindingFingerprinter) Fingerprint(descriptor CredentialBindingDescriptor) string { + descriptor = canonicalDescriptor(descriptor) + data, err := json.Marshal(descriptor) + if err != nil || len(f.key) == 0 { + return "" + } + mac := hmac.New(sha256.New, f.key) + _, _ = mac.Write(data) + return hex.EncodeToString(mac.Sum(nil)) +} + +func (f *BindingFingerprinter) FallbackFingerprint(profileID string) string { + return "profile:" + profileID +} + +func canonicalDescriptor(descriptor CredentialBindingDescriptor) CredentialBindingDescriptor { + descriptor.AgentFamilyID = strings.TrimSpace(strings.ToLower(descriptor.AgentFamilyID)) + descriptor.AuthenticationMethod = strings.TrimSpace(strings.ToLower(descriptor.AuthenticationMethod)) + descriptor.CredentialSourceKind = strings.TrimSpace(strings.ToLower(descriptor.CredentialSourceKind)) + descriptor.CredentialLocator = strings.TrimSpace(descriptor.CredentialLocator) + descriptor.ExecutorNamespace = strings.TrimSpace(descriptor.ExecutorNamespace) + descriptor.AuthorizationScope = strings.TrimSpace(descriptor.AuthorizationScope) + descriptor.WorkspaceScope = strings.TrimSpace(descriptor.WorkspaceScope) + return descriptor +} diff --git a/apps/backend/internal/agent/runtime/dynamic/types.go b/apps/backend/internal/agent/runtime/dynamic/types.go new file mode 100644 index 0000000000..347f62852f --- /dev/null +++ b/apps/backend/internal/agent/runtime/dynamic/types.go @@ -0,0 +1,162 @@ +// Package dynamic owns provider-neutral routing for dynamic agent profiles. +// It deliberately depends only on the normalized runtime error contract, so +// concrete launch adapters and Office do not need to import each other. +package dynamic + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/kandev/kandev/internal/agent/runtime/routingerr" + "github.com/kandev/kandev/internal/agent/runtime/routingpolicy" +) + +type Action string + +const ( + ActionRetrySame Action = "retry_same" + ActionTryNext Action = "try_next" + ActionStop Action = "stop" +) + +var ( + ErrStaleGeneration = errors.New("dynamic route generation is stale") + ErrNoEligibleCandidate = errors.New("dynamic profile has no eligible candidate") + ErrRouteStateNotFound = errors.New("dynamic route state not found") + ErrRecoveryPending = errors.New("dynamic route recovery is pending") + ErrRecoveryNotDue = errors.New("dynamic route recovery is not due") +) + +type Candidate struct { + ID string + Enabled bool + BindingKey string + Rules map[string]Action + Policies routingpolicy.Document +} + +type Profile struct { + ID string + Version int64 + Candidates []Candidate +} + +type RouteState struct { + SessionID string + LogicalProfileID string + ExecutionProfileID string + Generation int64 + ProfileVersion int64 + Status string + ContinuationJSON string + PolicyStateJSON string + UpdatedAt time.Time +} + +// PolicyState is the durable snapshot of one evaluated provider failure. It +// is stored as JSON so older installations can add fields without changing +// candidate or session identity columns. +type PolicyState struct { + FailureCode routingerr.Code `json:"failure_code"` + FailureClass routingerr.Class `json:"failure_class"` + CatalogueVersion string `json:"catalogue_version"` + PolicyJSON string `json:"policy_json"` + RetryOrdinal int64 `json:"retry_ordinal"` + ResetWaitUsed bool `json:"reset_wait_used"` + ResetWaitClasses map[routingerr.Class]bool `json:"reset_wait_classes,omitempty"` + Deadline *time.Time `json:"deadline,omitempty"` + PendingOutcome routingpolicy.Outcome `json:"pending_outcome"` +} + +type RouteDecision struct { + SessionID string + LogicalProfileID string + ExecutionProfileID string + Generation int64 + ProfileVersion int64 + Reason string + Status string + Deadline *time.Time + ErrorCode routingerr.Code + ErrorClass routingerr.Class + CatalogueVersion string + RetryOrdinal int64 + PendingOutcome routingpolicy.Outcome +} + +type RouteAttempt struct { + SessionID string + LogicalProfileID string + ExecutionProfileID string + Generation int64 + ProfileVersion int64 + Reason string + CreatedAt time.Time +} + +// ContinuationRecord is the bounded handoff package persisted for a route +// generation before a successor launch. It contains context, not provider +// native session state. +type ContinuationRecord struct { + SessionID string + Generation int64 + Continuation Continuation + UpdatedAt time.Time +} + +// ContinuationPersistence stores the handoff package with the route +// generation that owns it. Implementations must reject a stale generation. +type ContinuationPersistence interface { + SaveRouteContinuation(context.Context, ContinuationRecord) error +} + +// Persistence is the narrow durable seam for route state and immutable +// attempts. The task repository implements it without importing the routing +// engine's selection logic. +type Persistence interface { + SaveRouteState(context.Context, RouteState) error + AppendRouteAttempt(context.Context, RouteAttempt) error +} + +// StateLoader is an optional restart-recovery seam. Implementations return +// (nil, nil) when no route state exists for the session. +type StateLoader interface { + LoadRouteState(context.Context, string) (*RouteState, error) +} + +// GenerationClaimer is the durable compare-and-swap seam. A false result +// means another worker already advanced the session generation. +type GenerationClaimer interface { + ClaimRouteState(context.Context, int64, RouteState) (bool, error) +} + +// DecisionRecorder lets a repository commit the state row and immutable +// attempt row in one transaction. Persistence remains backwards compatible +// for callers that only need the narrow Save/Append contract. +type DecisionRecorder interface { + RecordRouteDecision(context.Context, RouteDecision, RouteState) error +} + +type NoEligibleCandidateError struct { + SessionID string + LogicalProfile string + Generation int64 +} + +func (e *NoEligibleCandidateError) Error() string { + return fmt.Sprintf("%s: session=%s profile=%s generation=%d", ErrNoEligibleCandidate, e.SessionID, e.LogicalProfile, e.Generation) +} + +func (e *NoEligibleCandidateError) Unwrap() error { return ErrNoEligibleCandidate } + +func actionForRules(rules map[string]Action, code routingerr.Code) Action { + if action, ok := rules[string(code)]; ok { + return action + } + if action, ok := rules["on_provider_error"]; ok { + return action + } + return ActionStop +} diff --git a/apps/backend/internal/agent/runtime/dynamic_resolver.go b/apps/backend/internal/agent/runtime/dynamic_resolver.go new file mode 100644 index 0000000000..1836179c74 --- /dev/null +++ b/apps/backend/internal/agent/runtime/dynamic_resolver.go @@ -0,0 +1,616 @@ +package runtime + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "sync/atomic" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/runtime/dynamic" + "github.com/kandev/kandev/internal/agent/runtime/routingerr" + "github.com/kandev/kandev/internal/agent/runtime/routingpolicy" + agentsettingsmodels "github.com/kandev/kandev/internal/agent/settings/models" + "github.com/kandev/kandev/internal/agent/settings/store" +) + +var ErrDynamicRoutingDisabled = errors.New("dynamic agent routing is disabled") + +// ProfileExecution is the caller-facing result of resolving a logical +// profile. Concrete callers receive the same ID for both fields. Dynamic +// callers retain their logical ID while the resolver records the concrete +// candidate that owns the downstream launch. +type ProfileExecution struct { + LogicalProfileID string + ExecutionProfileID string + RouteSessionID string + Generation int64 + ProfileVersion int64 + Profile *agentsettingsmodels.AgentProfile + Decision dynamic.RouteDecision +} + +// ProfileExecutionResolver is the shared profile-kind boundary. Callers pass +// one profile ID and never need to branch on the dynamic family themselves. +// It intentionally returns a route decision rather than launching an agent; +// the conductor/lifecycle layer owns downstream ACP sessions. +type ProfileExecutionResolver struct { + profiles store.Repository + dynamic store.DynamicProfileRepository + engine *dynamic.Engine + bindingResolver *dynamic.CredentialBindingResolver + enabled atomic.Bool +} + +func NewProfileExecutionResolver(profiles store.Repository, engine *dynamic.Engine, enabled bool) *ProfileExecutionResolver { + var dynamicRepo store.DynamicProfileRepository + if repo, ok := profiles.(store.DynamicProfileRepository); ok { + dynamicRepo = repo + } + resolver := &ProfileExecutionResolver{profiles: profiles, dynamic: dynamicRepo, engine: engine} + resolver.enabled.Store(enabled) + return resolver +} + +func (r *ProfileExecutionResolver) SetEnabled(enabled bool) { r.enabled.Store(enabled) } + +// SetCredentialBindingResolver supplies the installation-scoped fingerprint +// used to share provider health between concrete profiles that prove the same +// credential binding. A missing or incomplete descriptor remains isolated to +// the concrete profile through the resolver's conservative fallback. +func (r *ProfileExecutionResolver) SetCredentialBindingResolver(resolver *dynamic.CredentialBindingResolver) { + r.bindingResolver = resolver +} + +// NewConductor creates the lifecycle-facing conductor with the same engine, +// profile loader, and feature-gate state as this resolver. +func (r *ProfileExecutionResolver) NewConductor( + downstream dynamic.DownstreamRuntime, + options ...dynamic.ConductorOption, +) *dynamic.Conductor { + if persistence := r.engine.ContinuationPersistence(); persistence != nil { + options = append(options, dynamic.WithContinuationPersistence(persistence)) + } + return dynamic.NewConductor(r.engine, r, downstream, options...) +} + +// ValidateProfile performs the disabled-mode check without claiming a route +// generation or writing any durable state. Callers use it before creating a +// task session so a stored dynamic profile remains inert while the feature is +// disabled. +func (r *ProfileExecutionResolver) ValidateProfile(ctx context.Context, profileID string) error { + if profileID == "" { + // The ordinary launch path may resolve a workspace or workflow default + // later. There is no profile family to gate until that resolution has + // produced an ID. + return nil + } + if r.profiles == nil { + return errors.New("profile execution resolver has no profile store") + } + profile, err := r.profiles.GetAgentProfile(ctx, profileID) + if err != nil { + return fmt.Errorf("validate profile %s: %w", profileID, err) + } + agent, err := r.profiles.GetAgent(ctx, profile.AgentID) + if err != nil { + return fmt.Errorf("validate profile family %s: %w", profileID, err) + } + if agent.Name == agents.DynamicAgentID && !r.enabled.Load() { + return ErrDynamicRoutingDisabled + } + return nil +} + +// ResolveExecution preserves the small utility resolver contract for callers +// that do not carry a session identity. +func (r *ProfileExecutionResolver) ResolveExecution(ctx context.Context, profileID string) (*agentsettingsmodels.AgentProfile, string, error) { + return r.ResolveExecutionForSession(ctx, "", profileID) +} + +// LoadDynamicProfile implements dynamic.ProfileLoader for the conductor. It +// returns the same ordered, fail-closed candidate view used by ordinary +// profile resolution, without claiming a route generation. +func (r *ProfileExecutionResolver) LoadDynamicProfile(ctx context.Context, profileID string) (dynamic.Profile, error) { + return r.loadDynamicProfile(ctx, profileID) +} + +// ResolveExecutionForSession resolves one logical profile for a caller that +// has a durable session identity. Concrete profiles pass through unchanged; +// dynamic profiles claim a generation and return the selected concrete row. +func (r *ProfileExecutionResolver) ResolveExecutionForSession(ctx context.Context, sessionID, profileID string) (*agentsettingsmodels.AgentProfile, string, error) { + execution, err := r.ResolveExecutionDetails(ctx, sessionID, profileID) + if err != nil { + return nil, "", err + } + return execution.Profile, execution.ExecutionProfileID, nil +} + +// ResolveExecutionDetails returns the concrete profile and route metadata for +// a logical profile. Sessionless utility calls receive an isolated route ID. +func (r *ProfileExecutionResolver) ResolveExecutionDetails(ctx context.Context, sessionID, profileID string) (ProfileExecution, error) { + profile, err := r.profiles.GetAgentProfile(ctx, profileID) + if err != nil { + return ProfileExecution{}, fmt.Errorf("resolve profile %s: %w", profileID, err) + } + agent, err := r.profiles.GetAgent(ctx, profile.AgentID) + if err != nil { + return ProfileExecution{}, fmt.Errorf("resolve profile family %s: %w", profileID, err) + } + if agent.Name != agents.DynamicAgentID { + return ProfileExecution{LogicalProfileID: profileID, ExecutionProfileID: profile.ID, Profile: profile}, nil + } + if !r.enabled.Load() { + return ProfileExecution{}, ErrDynamicRoutingDisabled + } + // Utility calls always get an isolated route state, even when the template + // context belongs to an already-routed task session. Reusing that session + // would consume or fence the task's durable generation. + routeSessionID := "utility:" + uuid.NewString() + decision, err := r.Resolve(ctx, routeSessionID, profileID, 0, "") + if err != nil { + return ProfileExecution{}, err + } + decision.RouteSessionID = routeSessionID + return decision, nil +} + +// ResolveExecutionAfterFailure applies a classified prompt failure and +// returns the next concrete execution profile for the same logical route. +func (r *ProfileExecutionResolver) ResolveExecutionAfterFailure( + ctx context.Context, + sessionID, profileID, currentExecutionProfileID string, + expectedGeneration int64, + failure *routingerr.Error, +) (ProfileExecution, error) { + if r.profiles == nil || r.engine == nil { + return ProfileExecution{}, errors.New("dynamic profile execution is not configured") + } + if err := r.ValidateProfile(ctx, profileID); err != nil { + return ProfileExecution{}, err + } + profile, err := r.profiles.GetAgentProfile(ctx, profileID) + if err != nil { + return ProfileExecution{}, err + } + agent, err := r.profiles.GetAgent(ctx, profile.AgentID) + if err != nil { + return ProfileExecution{}, err + } + if agent.Name != agents.DynamicAgentID { + return ProfileExecution{LogicalProfileID: profileID, ExecutionProfileID: profile.ID, Profile: profile}, nil + } + if sessionID == "" { + sessionID = "utility:" + uuid.NewString() + } + profileConfig, err := r.loadDynamicProfile(ctx, profileID) + if err != nil { + return ProfileExecution{}, err + } + decision, err := r.engine.ApplyFailureContext(ctx, sessionID, profileConfig, expectedGeneration, currentExecutionProfileID, failure) + if err != nil { + return ProfileExecution{}, err + } + concrete, err := r.profiles.GetAgentProfile(ctx, decision.ExecutionProfileID) + if err != nil { + return ProfileExecution{}, fmt.Errorf("resolve execution profile %s: %w", decision.ExecutionProfileID, err) + } + return ProfileExecution{ + LogicalProfileID: profileID, ExecutionProfileID: decision.ExecutionProfileID, + RouteSessionID: sessionID, Generation: decision.Generation, + ProfileVersion: decision.ProfileVersion, Profile: concrete, Decision: decision, + }, nil +} + +// ResolveExisting returns the persisted concrete execution for a logical +// session without advancing its route generation. It is used by resume paths +// after a restart, where selecting again would either fence a valid session +// or silently move it to another candidate. +func (r *ProfileExecutionResolver) ResolveExisting( + ctx context.Context, + sessionID, profileID, executionProfileID string, + generation, profileVersion int64, + reason string, +) (ProfileExecution, error) { + if err := r.ValidateProfile(ctx, profileID); err != nil { + return ProfileExecution{}, err + } + profile, err := r.profiles.GetAgentProfile(ctx, profileID) + if err != nil { + return ProfileExecution{}, err + } + agent, err := r.profiles.GetAgent(ctx, profile.AgentID) + if err != nil { + return ProfileExecution{}, err + } + if agent.Name != agents.DynamicAgentID { + return ProfileExecution{LogicalProfileID: profileID, ExecutionProfileID: profileID, Profile: profile}, nil + } + if executionProfileID == "" || generation <= 0 { + return ProfileExecution{}, errors.New("dynamic session has no persisted execution profile") + } + concrete, err := r.profiles.GetAgentProfile(ctx, executionProfileID) + if err != nil { + return ProfileExecution{}, fmt.Errorf("resolve existing execution profile %s: %w", executionProfileID, err) + } + if concrete == nil || concrete.DeletedAt != nil || !concrete.Enabled { + return ProfileExecution{}, fmt.Errorf("existing execution profile %s is unavailable", executionProfileID) + } + return ProfileExecution{ + LogicalProfileID: profileID, ExecutionProfileID: executionProfileID, + Generation: generation, ProfileVersion: profileVersion, Profile: concrete, + Decision: dynamic.RouteDecision{ + SessionID: sessionID, LogicalProfileID: profileID, + ExecutionProfileID: executionProfileID, Generation: generation, + ProfileVersion: profileVersion, Reason: reason, + }, + }, nil +} + +func (r *ProfileExecutionResolver) Resolve(ctx context.Context, sessionID, profileID string, expectedGeneration int64, excludeProfileID string) (ProfileExecution, error) { + return r.resolve(ctx, sessionID, profileID, expectedGeneration, excludeProfileID, "") +} + +// ResolveWithPreference keeps the current concrete candidate for an explicit +// retry when it remains eligible. Try-next callers continue to use Resolve and +// pass the current candidate as the one-time exclusion. +func (r *ProfileExecutionResolver) ResolveWithPreference( + ctx context.Context, + sessionID, profileID string, + expectedGeneration int64, + excludeProfileID, preferredProfileID string, +) (ProfileExecution, error) { + return r.resolve(ctx, sessionID, profileID, expectedGeneration, excludeProfileID, preferredProfileID) +} + +// ResolveRouteAction applies a manual route action to the durable route +// state. Retry resumes the current generation, even when its policy deadline +// has not elapsed; try-next claims a new generation and excludes the current +// candidate. The caller can then launch the returned concrete profile through +// the normal conductor path. +func (r *ProfileExecutionResolver) ResolveRouteAction( + ctx context.Context, + sessionID, profileID, currentExecutionProfileID string, + expectedGeneration int64, + action string, +) (ProfileExecution, error) { + if r.profiles == nil { + return ProfileExecution{}, errors.New("profile execution resolver has no profile store") + } + profile, err := r.profiles.GetAgentProfile(ctx, profileID) + if err != nil { + return ProfileExecution{}, fmt.Errorf("resolve profile %s: %w", profileID, err) + } + agent, err := r.profiles.GetAgent(ctx, profile.AgentID) + if err != nil { + return ProfileExecution{}, fmt.Errorf("resolve profile family %s: %w", profileID, err) + } + if agent.Name != agents.DynamicAgentID { + return ProfileExecution{LogicalProfileID: profileID, ExecutionProfileID: profile.ID, Profile: profile}, nil + } + if !r.enabled.Load() { + return ProfileExecution{}, ErrDynamicRoutingDisabled + } + if r.dynamic == nil || r.engine == nil { + return ProfileExecution{}, errors.New("dynamic profile execution is not configured") + } + if sessionID == "" { + return ProfileExecution{}, errors.New("dynamic route action requires a session") + } + + switch action { + case "retry": + return r.resolveRetryRouteAction(ctx, sessionID, profileID, currentExecutionProfileID, expectedGeneration) + case "try_next", "skip": + return r.resolveSkipRouteAction(ctx, sessionID, profileID, currentExecutionProfileID, expectedGeneration) + case "cancel_wait", "stop": + return r.resolveCancelRouteAction(ctx, sessionID, profileID, expectedGeneration, action) + default: + return ProfileExecution{}, fmt.Errorf("unsupported dynamic route action %q", action) + } +} + +func (r *ProfileExecutionResolver) resolveRetryRouteAction( + ctx context.Context, + sessionID, profileID, currentExecutionProfileID string, + expectedGeneration int64, +) (ProfileExecution, error) { + state, exists, err := r.engine.LoadState(ctx, sessionID) + if err != nil { + return ProfileExecution{}, err + } + if exists && state.Generation != expectedGeneration { + return ProfileExecution{}, dynamic.ErrStaleGeneration + } + if exists { + decision, resumeErr := r.engine.ResumePendingNow(ctx, sessionID, expectedGeneration) + if resumeErr == nil { + return r.executionFromDecision(ctx, profileID, sessionID, decision) + } + if !errors.Is(resumeErr, dynamic.ErrRecoveryPending) && !errors.Is(resumeErr, dynamic.ErrRouteStateNotFound) { + return ProfileExecution{}, resumeErr + } + } + return r.resolve(ctx, sessionID, profileID, expectedGeneration, "", currentExecutionProfileID) +} + +func (r *ProfileExecutionResolver) resolveSkipRouteAction( + ctx context.Context, + sessionID, profileID, currentExecutionProfileID string, + expectedGeneration int64, +) (ProfileExecution, error) { + profileConfig, err := r.loadDynamicProfile(ctx, profileID) + if err != nil { + return ProfileExecution{}, err + } + if currentExecutionProfileID == "" { + state, exists, stateErr := r.engine.LoadState(ctx, sessionID) + if stateErr != nil { + return ProfileExecution{}, stateErr + } + if exists { + currentExecutionProfileID = state.ExecutionProfileID + } + } + decision, err := r.engine.SelectContextWithReason( + ctx, sessionID, profileConfig, expectedGeneration, currentExecutionProfileID, "manual_skip", + ) + if err != nil { + return ProfileExecution{}, err + } + return r.executionFromDecision(ctx, profileID, sessionID, decision) +} + +func (r *ProfileExecutionResolver) resolveCancelRouteAction( + ctx context.Context, + sessionID, profileID string, + expectedGeneration int64, + action string, +) (ProfileExecution, error) { + reason := "manual_cancel_wait" + if action == "stop" { + reason = "manual_stop" + } + decision, err := r.engine.CancelPending(ctx, sessionID, expectedGeneration, reason) + if err != nil { + return ProfileExecution{}, err + } + return r.executionFromDecision(ctx, profileID, sessionID, decision) +} + +// ResumePendingRoute advances a due durable policy wait/retry without +// selecting another generation. It is used by the orchestrator's recovery +// scheduler after the persisted deadline has elapsed. +func (r *ProfileExecutionResolver) ResumePendingRoute( + ctx context.Context, + sessionID string, + expectedGeneration int64, +) (ProfileExecution, error) { + if r.engine == nil || r.profiles == nil { + return ProfileExecution{}, errors.New("dynamic profile execution is not configured") + } + state, exists, err := r.engine.LoadState(ctx, sessionID) + if err != nil { + return ProfileExecution{}, err + } + if !exists { + return ProfileExecution{}, dynamic.ErrRouteStateNotFound + } + decision, err := r.engine.ResumePending(ctx, sessionID, expectedGeneration) + if err != nil { + return ProfileExecution{}, err + } + return r.executionFromDecision(ctx, state.LogicalProfileID, sessionID, decision) +} + +func (r *ProfileExecutionResolver) resolve( + ctx context.Context, + sessionID, profileID string, + expectedGeneration int64, + excludeProfileID, preferredProfileID string, +) (ProfileExecution, error) { + if r.profiles == nil { + return ProfileExecution{}, errors.New("profile execution resolver has no profile store") + } + profile, err := r.profiles.GetAgentProfile(ctx, profileID) + if err != nil { + return ProfileExecution{}, fmt.Errorf("resolve profile %s: %w", profileID, err) + } + agent, err := r.profiles.GetAgent(ctx, profile.AgentID) + if err != nil { + return ProfileExecution{}, fmt.Errorf("resolve profile family %s: %w", profileID, err) + } + if agent.Name != agents.DynamicAgentID { + return ProfileExecution{ + LogicalProfileID: profileID, ExecutionProfileID: profileID, Profile: profile, + }, nil + } + if !r.enabled.Load() { + return ProfileExecution{}, ErrDynamicRoutingDisabled + } + if r.dynamic == nil || r.engine == nil { + return ProfileExecution{}, errors.New("dynamic profile execution is not configured") + } + profileConfig, err := r.loadDynamicProfile(ctx, profileID) + if err != nil { + return ProfileExecution{}, err + } + decision, err := r.engine.SelectContextWithPreference( + ctx, sessionID, profileConfig, expectedGeneration, excludeProfileID, preferredProfileID, + ) + if err != nil { + return ProfileExecution{}, err + } + concrete, err := r.profiles.GetAgentProfile(ctx, decision.ExecutionProfileID) + if err != nil { + return ProfileExecution{}, fmt.Errorf("resolve execution profile %s: %w", decision.ExecutionProfileID, err) + } + return ProfileExecution{ + LogicalProfileID: profileID, ExecutionProfileID: decision.ExecutionProfileID, + RouteSessionID: sessionID, + Generation: decision.Generation, ProfileVersion: decision.ProfileVersion, + Profile: concrete, + Decision: decision, + }, nil +} + +func (r *ProfileExecutionResolver) executionFromDecision( + ctx context.Context, + profileID, sessionID string, + decision dynamic.RouteDecision, +) (ProfileExecution, error) { + concrete, err := r.profiles.GetAgentProfile(ctx, decision.ExecutionProfileID) + if err != nil { + return ProfileExecution{}, fmt.Errorf("resolve execution profile %s: %w", decision.ExecutionProfileID, err) + } + if concrete == nil || concrete.DeletedAt != nil || !concrete.Enabled { + return ProfileExecution{}, fmt.Errorf("execution profile %s is unavailable", decision.ExecutionProfileID) + } + return ProfileExecution{ + LogicalProfileID: profileID, ExecutionProfileID: decision.ExecutionProfileID, + RouteSessionID: sessionID, Generation: decision.Generation, + ProfileVersion: decision.ProfileVersion, Profile: concrete, Decision: decision, + }, nil +} + +func (r *ProfileExecutionResolver) loadDynamicProfile(ctx context.Context, profileID string) (dynamic.Profile, error) { + if !r.enabled.Load() { + return dynamic.Profile{}, ErrDynamicRoutingDisabled + } + if r.dynamic == nil { + return dynamic.Profile{}, errors.New("dynamic profile execution is not configured") + } + config, routes, err := r.dynamic.GetDynamicAgentProfile(ctx, profileID) + if err != nil { + return dynamic.Profile{}, fmt.Errorf("load dynamic profile %s: %w", profileID, err) + } + profile := dynamic.Profile{ + ID: profileID, Version: config.Version, + Candidates: make([]dynamic.Candidate, 0, len(routes)), + } + for _, route := range routes { + candidate := dynamic.Candidate{ + ID: route.ExecutionProfileID, Enabled: route.Enabled, + BindingKey: dynamic.ResourceKey(dynamic.ScopeProfile, route.ExecutionProfileID), + } + if route.RulesJSON != "" { + policy, legacyRules, policyErr := decodeDynamicRoutePolicy(route.RulesJSON) + if policyErr != nil { + return dynamic.Profile{}, fmt.Errorf("decode dynamic route %s: %w", route.ExecutionProfileID, policyErr) + } + candidate.Policies = policy + candidate.Rules = legacyRules + } + concrete, profileErr := r.profiles.GetAgentProfile(ctx, route.ExecutionProfileID) + switch { + case profileErr != nil: + if errors.Is(profileErr, sql.ErrNoRows) || errors.Is(profileErr, store.ErrAgentProfileDeleted) { + candidate.Enabled = false + } else { + return dynamic.Profile{}, fmt.Errorf("load dynamic candidate %s: %w", route.ExecutionProfileID, profileErr) + } + case concrete == nil || concrete.DeletedAt != nil || !concrete.Enabled: + candidate.Enabled = false + case r.bindingResolver != nil: + binding := profileCredentialBindingDescriptor(concrete) + candidate.BindingKey = dynamic.ResourceKey( + dynamic.ScopeCredential, + r.bindingResolver.Resolve(binding, route.ExecutionProfileID), + ) + } + profile.Candidates = append(profile.Candidates, candidate) + } + return profile, nil +} + +func decodeDynamicRoutePolicy(raw string) (routingpolicy.Document, map[string]dynamic.Action, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &fields); err != nil { + return routingpolicy.Document{}, nil, err + } + if _, hasVersion := fields["version"]; hasVersion { + var document routingpolicy.Document + if err := json.Unmarshal([]byte(raw), &document); err != nil { + return routingpolicy.Document{}, nil, err + } + if err := routingpolicy.ValidateDocument(document); err != nil { + return routingpolicy.Document{}, nil, err + } + return document, nil, nil + } + var legacy map[string]dynamic.Action + if err := json.Unmarshal([]byte(raw), &legacy); err != nil { + return routingpolicy.Document{}, nil, err + } + document := routingpolicy.DefaultDocument() + classActions := make(map[routingerr.Class]dynamic.Action) + for key, action := range legacy { + if key == "on_provider_error" { + document.Transient = legacyActionPolicy(action) + document.Hard = legacyActionPolicy(action) + continue + } + class := routingerr.ClassForCode(routingerr.Code(key)) + if class != routingerr.ClassTransient && class != routingerr.ClassHard { + return routingpolicy.Document{}, nil, fmt.Errorf("legacy rule %q is not a provider error code", key) + } + if previous, ok := classActions[class]; ok && previous != action { + return routingpolicy.Document{}, nil, fmt.Errorf("legacy rules conflict for %s errors", class) + } + classActions[class] = action + if class == routingerr.ClassTransient { + document.Transient = legacyActionPolicy(action) + } else { + document.Hard = legacyActionPolicy(action) + } + } + if err := routingpolicy.ValidateDocument(document); err != nil { + return routingpolicy.Document{}, nil, err + } + return document, legacy, nil +} + +func legacyActionPolicy(action dynamic.Action) routingpolicy.Policy { + policy := routingpolicy.DefaultPolicy() + switch action { + case dynamic.ActionRetrySame: + policy.Retry = routingpolicy.RetryPolicy{Enabled: true, MaxRetries: 1, InitialIntervalSeconds: 5} + policy.OnExhausted = routingpolicy.OutcomeStop + case dynamic.ActionStop: + policy.OnExhausted = routingpolicy.OutcomeStop + } + return policy +} + +func profileCredentialBindingDescriptor(profile *agentsettingsmodels.AgentProfile) dynamic.CredentialBindingDescriptor { + if profile == nil { + return dynamic.CredentialBindingDescriptor{} + } + descriptor := dynamic.CredentialBindingDescriptor{ + Version: 1, + AgentFamilyID: profile.AgentID, + AuthenticationMethod: strings.TrimSpace(profile.BillingType), + ExecutorNamespace: "local", + AuthorizationScope: "agent_runtime", + } + secretIDs := make([]string, 0, len(profile.EnvVars)) + for _, envVar := range profile.EnvVars { + if strings.TrimSpace(envVar.SecretID) != "" { + secretIDs = append(secretIDs, strings.TrimSpace(envVar.SecretID)) + } + } + if len(secretIDs) > 0 { + sort.Strings(secretIDs) + descriptor.CredentialSourceKind = "profile_secret" + descriptor.CredentialLocator = strings.Join(secretIDs, ",") + } else if descriptor.AuthenticationMethod != "" { + descriptor.CredentialSourceKind = "agent_credentials" + descriptor.CredentialLocator = profile.AgentID + ":" + descriptor.AuthenticationMethod + } + return descriptor +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go b/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go index 01c69cf651..ca454d6a42 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go +++ b/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" + "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/agent/settings/models" "github.com/kandev/kandev/internal/agent/settings/store" @@ -20,6 +21,11 @@ type DeletedProfileError struct { ProfileName string } +// ErrVirtualProfile marks a profile family that is a routing owner rather +// than a concrete subprocess. Direct lifecycle launches must stop here; the +// shared dynamic conductor is the only owner allowed to select a candidate. +var ErrVirtualProfile = errors.New("agent profile belongs to a virtual execution family") + func (e *DeletedProfileError) Error() string { if e.ProfileName != "" { return fmt.Sprintf("agent profile %q (%s) was removed", e.ProfileName, e.ProfileID) @@ -65,6 +71,9 @@ func (r *StoreProfileResolver) ResolveProfile(ctx context.Context, profileID str if err := r.migrateCursorProfile(ctx, profile, agent); err != nil { return nil, err } + if agent.Name == agents.DynamicAgentID { + return nil, fmt.Errorf("profile %s: %w", profile.ID, ErrVirtualProfile) + } // Resolve agent capabilities from the registry. model, nativeSessionResume := r.resolveAgentCapabilities(agent.Name, profile.Model) diff --git a/apps/backend/internal/agent/runtime/routingerr/catalogue_test.go b/apps/backend/internal/agent/runtime/routingerr/catalogue_test.go new file mode 100644 index 0000000000..5e2bec7d5c --- /dev/null +++ b/apps/backend/internal/agent/runtime/routingerr/catalogue_test.go @@ -0,0 +1,120 @@ +package routingerr + +import ( + "net/http" + "reflect" + "testing" +) + +func TestClassForCodeCoversProviderCatalogue(t *testing.T) { + tests := []struct { + code Code + want Class + }{ + {CodeNetworkUnavailable, ClassTransient}, + {CodeProviderUnavailable, ClassTransient}, + {CodeProviderOverloaded, ClassTransient}, + {CodeModelCapacity, ClassTransient}, + {CodeRateLimited, ClassTransient}, + {CodeAgentTransportLost, ClassTransient}, + {CodeAuthRequired, ClassHard}, + {CodeMissingCredentials, ClassHard}, + {CodeSubscriptionRequired, ClassHard}, + {CodeQuotaLimited, ClassHard}, + {CodeModelUnavailable, ClassHard}, + {CodeProviderNotConfigured, ClassHard}, + {CodeUnknownProvider, ClassUnclassified}, + {CodeAgentRuntime, ClassUnclassified}, + {CodeTask, ClassUnclassified}, + {CodeRepo, ClassUnclassified}, + {CodePermissionDeniedByUser, ClassUnclassified}, + {CodeNpxCacheCorrupted, ClassUnclassified}, + {CodeManagedRuntimeNpmResolution, ClassUnclassified}, + {CodeResumeCorrupted, ClassUnclassified}, + } + + for _, test := range tests { + if got := ClassForCode(test.code); got != test.want { + t.Errorf("ClassForCode(%q) = %q, want %q", test.code, got, test.want) + } + } + if got := ClassForCode(Code("future_provider_code")); got != ClassUnclassified { + t.Fatalf("unknown code class = %q, want %q", got, ClassUnclassified) + } +} + +func TestClassifyPersistsCatalogueVersion(t *testing.T) { + classified := Classify(Input{Phase: PhasePromptSend, Stderr: "temporary failure"}) + if classified.CatalogueVersion != CatalogueVersion { + t.Fatalf("catalogue version = %q, want %q", classified.CatalogueVersion, CatalogueVersion) + } +} + +func classifiedErrorClass(e *Error) string { + if e == nil { + return "" + } + value := reflect.ValueOf(e).Elem().FieldByName("Class") + if !value.IsValid() || value.Kind() != reflect.String { + return "" + } + return value.String() +} + +func TestClassifyAssignsSharedProviderErrorClasses(t *testing.T) { + tests := []struct { + name string + input Input + want string + }{ + { + name: "transient provider overload", + input: Input{ + Phase: PhasePromptSend, + HTTPStatus: statusOverloaded, + }, + want: "transient", + }, + { + name: "transient rate limit", + input: Input{ + Phase: PhasePromptSend, + HTTPStatus: http.StatusTooManyRequests, + }, + want: "transient", + }, + { + name: "hard subscription", + input: Input{ + Phase: PhasePromptSend, + HTTPStatus: http.StatusPaymentRequired, + }, + want: "hard", + }, + { + name: "hard authentication", + input: Input{ + Phase: PhasePromptSend, + HTTPStatus: http.StatusUnauthorized, + }, + want: "hard", + }, + { + name: "unknown fails closed", + input: Input{ + Phase: PhasePromptSend, + Stderr: "provider returned an unfamiliar failure", + }, + want: "unclassified", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + classified := Classify(test.input) + if got := classifiedErrorClass(classified); got != test.want { + t.Fatalf("Classify(...).Class = %q, want %q", got, test.want) + } + }) + } +} diff --git a/apps/backend/internal/agent/runtime/routingerr/routingerr.go b/apps/backend/internal/agent/runtime/routingerr/routingerr.go index 55af7d5267..cdc020daac 100644 --- a/apps/backend/internal/agent/runtime/routingerr/routingerr.go +++ b/apps/backend/internal/agent/runtime/routingerr/routingerr.go @@ -15,6 +15,22 @@ import ( // Code is the normalized provider-routing error code. type Code string +// Class is the shared recovery class for a normalized provider failure. The +// class is deliberately smaller than Code so the catalogue can grow without +// forcing every policy consumer to learn every provider-specific signature. +type Class string + +const ( + ClassTransient Class = "transient" + ClassHard Class = "hard" + ClassUnclassified Class = "unclassified" + + // CatalogueVersion identifies the deterministic code-to-class catalogue + // used for a classification. It is persisted with route decisions so later + // catalogue additions do not relabel historical attempts. + CatalogueVersion = "provider-errors.v1" +) + const ( CodeAuthRequired Code = "auth_required" CodeMissingCredentials Code = "missing_credentials" @@ -94,17 +110,34 @@ const ( // Error is the classifier's normalized output. It wraps stderr/stdout in // RawExcerpt after sanitization + truncation. type Error struct { - Code Code - Confidence Confidence - Phase Phase - FallbackAllowed bool - AutoRetryable bool - UserAction bool - ClassifierRule string - ExitCode *int - ResetHint *time.Time - RawExcerpt string - RemediationPath string // path to clean before retry; only set for codes that have a known remediation + Code Code + Class Class + CatalogueVersion string + Confidence Confidence + Phase Phase + FallbackAllowed bool + AutoRetryable bool + UserAction bool + ClassifierRule string + ExitCode *int + ResetHint *time.Time + RawExcerpt string + RemediationPath string // path to clean before retry; only set for codes that have a known remediation +} + +// ClassForCode returns the policy class assigned by the provider-error +// catalogue. Codes outside the provider recovery catalogue fail closed. +func ClassForCode(code Code) Class { + switch code { + case CodeNetworkUnavailable, CodeProviderUnavailable, CodeProviderOverloaded, + CodeModelCapacity, CodeRateLimited, CodeAgentTransportLost: + return ClassTransient + case CodeAuthRequired, CodeMissingCredentials, CodeSubscriptionRequired, + CodeQuotaLimited, CodeModelUnavailable, CodeProviderNotConfigured: + return ClassHard + default: + return ClassUnclassified + } } func (e *Error) Error() string { @@ -266,6 +299,8 @@ func classifyByPhase(in Input, excerpt string) *Error { } func applyInvariants(e *Error) *Error { + e.Class = ClassForCode(e.Code) + e.CatalogueVersion = CatalogueVersion switch e.Code { case CodeAuthRequired, CodeMissingCredentials, CodeSubscriptionRequired, CodeProviderNotConfigured: e.UserAction = true diff --git a/apps/backend/internal/agent/runtime/routingpolicy/policy.go b/apps/backend/internal/agent/runtime/routingpolicy/policy.go new file mode 100644 index 0000000000..115fadaac3 --- /dev/null +++ b/apps/backend/internal/agent/runtime/routingpolicy/policy.go @@ -0,0 +1,225 @@ +// Package routingpolicy evaluates the provider-error policy shared by dynamic +// profiles and every execution surface that can invoke one. +package routingpolicy + +import ( + "errors" + "fmt" + "math" + "time" + + "github.com/kandev/kandev/internal/agent/runtime/routingerr" +) + +const ( + Version int64 = 1 + + MinRetries int64 = 1 + MaxRetries int64 = 10 + MinInitialIntervalSeconds int64 = 1 + MaxInitialIntervalSeconds int64 = 3600 + MaxRetryDelay = 24 * time.Hour + MinResetWaitSeconds int64 = 1 + MaxResetWaitSeconds int64 = 7 * 24 * 60 * 60 +) + +type Outcome string + +const ( + OutcomeSkip Outcome = "skip" + OutcomeStop Outcome = "stop" +) + +type RetryPolicy struct { + Enabled bool `json:"enabled"` + MaxRetries int64 `json:"max_retries"` + InitialIntervalSeconds int64 `json:"initial_interval_seconds"` +} + +type ResetWaitPolicy struct { + Enabled bool `json:"enabled"` + MaxWaitSeconds int64 `json:"max_wait_seconds"` +} + +type Policy struct { + Retry RetryPolicy `json:"retry"` + WaitForReset ResetWaitPolicy `json:"wait_for_reset"` + OnExhausted Outcome `json:"on_exhausted"` +} + +type Document struct { + Version int64 `json:"version"` + Transient Policy `json:"transient"` + Hard Policy `json:"hard"` +} + +func DefaultPolicy() Policy { + return Policy{OnExhausted: OutcomeSkip} +} + +func DefaultDocument() Document { + return Document{Version: Version, Transient: DefaultPolicy(), Hard: DefaultPolicy()} +} + +func (document Document) PolicyFor(class routingerr.Class) (Policy, bool) { + switch class { + case routingerr.ClassTransient: + return document.Transient, true + case routingerr.ClassHard: + return document.Hard, true + default: + return Policy{}, false + } +} + +func ValidateDocument(document Document) error { + if document.Version != Version { + return fmt.Errorf("unsupported routing policy version %d", document.Version) + } + if err := ValidatePolicy(document.Transient); err != nil { + return fmt.Errorf("transient policy: %w", err) + } + if err := ValidatePolicy(document.Hard); err != nil { + return fmt.Errorf("hard policy: %w", err) + } + return nil +} + +func ValidatePolicy(policy Policy) error { + if policy.OnExhausted != OutcomeSkip && policy.OnExhausted != OutcomeStop { + return fmt.Errorf("on_exhausted must be %q or %q", OutcomeSkip, OutcomeStop) + } + if policy.Retry.Enabled { + if policy.Retry.MaxRetries < MinRetries || policy.Retry.MaxRetries > MaxRetries { + return fmt.Errorf("retry.max_retries must be between %d and %d", MinRetries, MaxRetries) + } + if policy.Retry.InitialIntervalSeconds < MinInitialIntervalSeconds || policy.Retry.InitialIntervalSeconds > MaxInitialIntervalSeconds { + return fmt.Errorf("retry.initial_interval_seconds must be between %d and %d", MinInitialIntervalSeconds, MaxInitialIntervalSeconds) + } + } else if policy.Retry.MaxRetries != 0 || policy.Retry.InitialIntervalSeconds != 0 { + return errors.New("disabled retry values must be zero") + } + if policy.WaitForReset.Enabled { + if policy.WaitForReset.MaxWaitSeconds < MinResetWaitSeconds || policy.WaitForReset.MaxWaitSeconds > MaxResetWaitSeconds { + return fmt.Errorf("wait_for_reset.max_wait_seconds must be between %d and %d", MinResetWaitSeconds, MaxResetWaitSeconds) + } + } else if policy.WaitForReset.MaxWaitSeconds != 0 { + return errors.New("disabled wait_for_reset value must be zero") + } + return nil +} + +type DecisionKind string + +const ( + DecisionWaitForReset DecisionKind = "waiting_for_reset" + DecisionRetry DecisionKind = "retry_wait" + DecisionSkip DecisionKind = "skip" + DecisionStop DecisionKind = "stop" +) + +type EvaluationInput struct { + Failure *routingerr.Error + Now time.Time + RetryOrdinal int64 + ResetWaitUsed bool + EffectSafe bool +} + +type Evaluation struct { + Kind DecisionKind + Class routingerr.Class + Code routingerr.Code + CatalogueVersion string + RetryOrdinal int64 + Deadline time.Time + PendingOutcome Outcome +} + +func Evaluate(document Document, input EvaluationInput) Evaluation { + result := Evaluation{Kind: DecisionStop, PendingOutcome: OutcomeStop} + if input.Failure == nil || !input.EffectSafe || !input.Failure.FallbackAllowed { + return result + } + class := failureClass(input.Failure) + policy, ok := document.PolicyFor(class) + if !ok || ValidateDocument(document) != nil { + return result + } + result.Class = class + result.Code = input.Failure.Code + result.CatalogueVersion = input.Failure.CatalogueVersion + if result.CatalogueVersion == "" { + result.CatalogueVersion = routingerr.CatalogueVersion + } + result.PendingOutcome = policy.OnExhausted + + if resetAt, ok := resetWaitDeadline(policy, input); ok { + result.Kind = DecisionWaitForReset + result.Deadline = resetAt + return result + } + if retryAt, retryOrdinal, ok := retryDeadline(policy, input); ok { + result.Kind = DecisionRetry + result.RetryOrdinal = retryOrdinal + result.Deadline = retryAt + return result + } + if policy.OnExhausted == OutcomeSkip { + result.Kind = DecisionSkip + } + return result +} + +func failureClass(failure *routingerr.Error) routingerr.Class { + if failure.Class != "" { + return failure.Class + } + return routingerr.ClassForCode(failure.Code) +} + +func resetWaitDeadline(policy Policy, input EvaluationInput) (time.Time, bool) { + if !policy.WaitForReset.Enabled || input.ResetWaitUsed || input.Failure.ResetHint == nil { + return time.Time{}, false + } + resetAt := input.Failure.ResetHint.UTC() + maxWait := time.Duration(policy.WaitForReset.MaxWaitSeconds) * time.Second + if !resetAt.After(input.Now) || resetAt.After(input.Now.Add(maxWait)) { + return time.Time{}, false + } + return resetAt, true +} + +func retryDeadline(policy Policy, input EvaluationInput) (time.Time, int64, bool) { + if !policy.Retry.Enabled || input.RetryOrdinal >= policy.Retry.MaxRetries { + return time.Time{}, 0, false + } + delay, err := NextRetryDelay(policy.Retry.InitialIntervalSeconds, input.RetryOrdinal) + if err != nil { + return time.Time{}, 0, false + } + return input.Now.Add(delay), input.RetryOrdinal + 1, true +} + +func NextRetryDelay(initialIntervalSeconds, retryOrdinal int64) (time.Duration, error) { + if initialIntervalSeconds < MinInitialIntervalSeconds || retryOrdinal < 0 { + return 0, errors.New("invalid retry interval or ordinal") + } + if initialIntervalSeconds > MaxInitialIntervalSeconds { + return 0, errors.New("retry interval exceeds limit") + } + if retryOrdinal >= 63 { + return MaxRetryDelay, nil + } + seconds := uint64(initialIntervalSeconds) + if retryOrdinal > 0 { + if retryOrdinal >= 64 || seconds > math.MaxUint64/(uint64(1)<= uint64(MaxRetryDelay/time.Second) { + return MaxRetryDelay, nil + } + return time.Duration(seconds) * time.Second, nil +} diff --git a/apps/backend/internal/agent/runtime/routingpolicy/policy_test.go b/apps/backend/internal/agent/runtime/routingpolicy/policy_test.go new file mode 100644 index 0000000000..23fdcec3a8 --- /dev/null +++ b/apps/backend/internal/agent/runtime/routingpolicy/policy_test.go @@ -0,0 +1,143 @@ +package routingpolicy + +import ( + "testing" + "time" + + "github.com/kandev/kandev/internal/agent/runtime/routingerr" +) + +func policyWithRetry(maxRetries int64) Policy { + policy := DefaultPolicy() + policy.Retry = RetryPolicy{Enabled: true, MaxRetries: maxRetries, InitialIntervalSeconds: 5} + return policy +} + +func safeFailure(class routingerr.Class) *routingerr.Error { + return &routingerr.Error{ + Code: routingerr.CodeRateLimited, + Class: class, + CatalogueVersion: routingerr.CatalogueVersion, + FallbackAllowed: true, + } +} + +func TestNextRetryDelayDoublesAndCaps(t *testing.T) { + tests := []struct { + ordinal int64 + want time.Duration + }{ + {ordinal: 0, want: 5 * time.Second}, + {ordinal: 1, want: 10 * time.Second}, + {ordinal: 2, want: 20 * time.Second}, + {ordinal: 30, want: MaxRetryDelay}, + } + for _, tt := range tests { + t.Run("ordinal-"+itoa(tt.ordinal), func(t *testing.T) { + got, err := NextRetryDelay(5, tt.ordinal) + if err != nil { + t.Fatalf("NextRetryDelay: %v", err) + } + if got != tt.want { + t.Fatalf("delay = %s, want %s", got, tt.want) + } + }) + } +} + +func TestEvaluateOrdersSafetyResetRetryAndExhaustion(t *testing.T) { + now := time.Date(2026, time.August, 17, 12, 0, 0, 0, time.UTC) + document := DefaultDocument() + document.Transient = policyWithRetry(2) + document.Transient.WaitForReset = ResetWaitPolicy{Enabled: true, MaxWaitSeconds: 300} + + tests := []struct { + name string + failure *routingerr.Error + effectSafe bool + retryOrdinal int64 + resetWaitUsed bool + wantKind DecisionKind + wantOrdinal int64 + wantDeadline time.Time + }{ + {name: "unsafe effect stops", failure: safeFailure(routingerr.ClassTransient), wantKind: DecisionStop}, + {name: "unclassified stops", failure: safeFailure(routingerr.ClassUnclassified), effectSafe: true, wantKind: DecisionStop}, + { + name: "trusted near reset waits once", + failure: withReset(safeFailure(routingerr.ClassTransient), now.Add(2*time.Minute)), + effectSafe: true, + wantKind: DecisionWaitForReset, + wantDeadline: now.Add(2 * time.Minute), + }, + { + name: "reset wait does not loop", + failure: withReset(safeFailure(routingerr.ClassTransient), now.Add(2*time.Minute)), + effectSafe: true, + resetWaitUsed: true, + wantKind: DecisionRetry, + wantOrdinal: 1, + wantDeadline: now.Add(5 * time.Second), + }, + { + name: "retry doubles", + failure: safeFailure(routingerr.ClassTransient), + effectSafe: true, + retryOrdinal: 1, + wantKind: DecisionRetry, + wantOrdinal: 2, + wantDeadline: now.Add(10 * time.Second), + }, + { + name: "retry exhaustion skips", + failure: safeFailure(routingerr.ClassTransient), + effectSafe: true, + retryOrdinal: 2, + wantKind: DecisionSkip, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Evaluate(document, EvaluationInput{ + Failure: tt.failure, Now: now, RetryOrdinal: tt.retryOrdinal, + ResetWaitUsed: tt.resetWaitUsed, EffectSafe: tt.effectSafe, + }) + if got.Kind != tt.wantKind { + t.Fatalf("kind = %s, want %s (%#v)", got.Kind, tt.wantKind, got) + } + if got.RetryOrdinal != tt.wantOrdinal { + t.Fatalf("retry ordinal = %d, want %d", got.RetryOrdinal, tt.wantOrdinal) + } + if !tt.wantDeadline.IsZero() && !got.Deadline.Equal(tt.wantDeadline) { + t.Fatalf("deadline = %s, want %s", got.Deadline, tt.wantDeadline) + } + }) + } +} + +func TestEvaluateHonorsStopAfterExhaustion(t *testing.T) { + document := DefaultDocument() + document.Hard = policyWithRetry(1) + document.Hard.OnExhausted = OutcomeStop + got := Evaluate(document, EvaluationInput{ + Failure: safeFailure(routingerr.ClassHard), + EffectSafe: true, + RetryOrdinal: 1, + Now: time.Unix(100, 0).UTC(), + }) + if got.Kind != DecisionStop || got.PendingOutcome != OutcomeStop { + t.Fatalf("evaluation = %#v, want stop", got) + } +} + +func withReset(failure *routingerr.Error, resetAt time.Time) *routingerr.Error { + failure.ResetHint = &resetAt + return failure +} + +func itoa(value int64) string { + if value == 0 { + return "0" + } + return "large" +} diff --git a/apps/backend/internal/agent/settings/controller/agent_crud.go b/apps/backend/internal/agent/settings/controller/agent_crud.go index d8e732a27c..963ff70ca2 100644 --- a/apps/backend/internal/agent/settings/controller/agent_crud.go +++ b/apps/backend/internal/agent/settings/controller/agent_crud.go @@ -28,6 +28,9 @@ func (c *Controller) GetAgent(ctx context.Context, id string) (*dto.AgentDTO, er return nil, err } result := toAgentDTO(agent, filterGlobalProfiles(profiles)) + if err := c.decorateAgentDTO(ctx, &result); err != nil { + return nil, err + } c.applyCapabilityStatus(&result, agent.Name) c.applyBillingType(&result, agent.Name) return &result, nil @@ -45,6 +48,9 @@ func (c *Controller) ListAgents(ctx context.Context) (*dto.ListAgentsResponse, e return nil, err } entry := toAgentDTO(agent, filterGlobalProfiles(profiles)) + if err := c.decorateAgentDTO(ctx, &entry); err != nil { + return nil, err + } c.applyCapabilityStatus(&entry, agent.Name) c.applyBillingType(&entry, agent.Name) payload = append(payload, entry) diff --git a/apps/backend/internal/agent/settings/controller/agent_discovery.go b/apps/backend/internal/agent/settings/controller/agent_discovery.go index 1ac9940739..c126c9ab19 100644 --- a/apps/backend/internal/agent/settings/controller/agent_discovery.go +++ b/apps/backend/internal/agent/settings/controller/agent_discovery.go @@ -575,9 +575,9 @@ func (c *Controller) detectAgents(ctx context.Context) ([]discovery.Availability } // synthAvailabilityFromRegistry builds Availability records for every enabled -// agent without hitting the filesystem. All agents are marked Available=true -// because in E2E mode only MockAgent instances are registered and they are -// always available by definition. +// inference agent without hitting the filesystem. Virtual families remain +// visible through their durable settings rows, but they are not discovery +// results and must never receive a concrete default profile. // // We still call IsInstalled() per-agent to copy over the agent's static // capability flags (SupportsMCP, MCPConfigPaths). Without those, downstream @@ -588,6 +588,9 @@ func (c *Controller) synthAvailabilityFromRegistry() []discovery.Availability { enabled := c.agentRegistry.ListEnabled() results := make([]discovery.Availability, 0, len(enabled)) for _, ag := range enabled { + if agents.IsVirtualAgent(ag) { + continue + } av := discovery.Availability{ Name: ag.ID(), Available: true, diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index 5c8ed20925..69182878cd 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -39,40 +39,55 @@ func buildCommandString(cmd []string) string { } var ( - ErrAgentNotFound = errors.New("agent not found") - ErrAgentAlreadyExists = errors.New("agent already exists") - ErrAgentProfileNotFound = errors.New("agent profile not found") - ErrAgentMcpUnsupported = errors.New("mcp not supported by agent") - ErrModelRequired = errors.New("model is required for agent profiles") - ErrLogoNotAvailable = errors.New("logo not available for agent") - ErrInvalidSlug = errors.New("display name must produce a valid slug") - ErrCommandRequired = errors.New("command is required") - ErrInvalidProfileEnvVars = errors.New("invalid profile env vars") - ErrInvalidCommandPrefix = errors.New("invalid command prefix") - ErrUnknownMCPStrategy = errors.New("unknown MCP strategy") - ErrNotCustomTUIAgent = errors.New("agent is not a custom TUI agent") + ErrAgentNotFound = errors.New("agent not found") + ErrAgentAlreadyExists = errors.New("agent already exists") + ErrAgentProfileNotFound = errors.New("agent profile not found") + ErrAgentMcpUnsupported = errors.New("mcp not supported by agent") + ErrModelRequired = errors.New("model is required for agent profiles") + ErrLogoNotAvailable = errors.New("logo not available for agent") + ErrInvalidSlug = errors.New("display name must produce a valid slug") + ErrCommandRequired = errors.New("command is required") + ErrInvalidProfileEnvVars = errors.New("invalid profile env vars") + ErrInvalidCommandPrefix = errors.New("invalid command prefix") + ErrUnknownMCPStrategy = errors.New("unknown MCP strategy") + ErrNotCustomTUIAgent = errors.New("agent is not a custom TUI agent") + ErrDynamicAgentRoutingDisabled = errors.New("dynamic agent routing is disabled") + ErrDynamicProfileCandidatesRequired = errors.New("dynamic profile candidates are required") + ErrDynamicProfilePositions = errors.New("dynamic profile candidate positions must be contiguous") + ErrDynamicProfileRule = errors.New("unsupported dynamic profile rule") + ErrDynamicProfileCandidate = errors.New("invalid dynamic profile candidate") + ErrDynamicProfileVersionConflict = store.ErrDynamicProfileVersionConflict + ErrDynamicProfileDuplicationUnsupported = errors.New("dynamic profile duplication is not supported") ) type Controller struct { - repo store.Repository - discovery *discovery.Registry - agentRegistry *registry.Registry - sessionChecker SessionChecker - watcherDeps WatcherDependencyChecker - routingTierDeps RoutingTierDependencyChecker - automationDeps AutomationDependencyChecker - utilityDeps UtilityDependencyChecker - mcpService *mcpconfig.Service - modelCache *modelfetcher.Cache - hostUtility hostUtilityProvider - jobStore *JobStore - updateJobStore *AgentUpdateJobStore - runtimeUpdater RuntimeUpdater - managedRuntimeSelections managedruntime.SelectionStore - maintenance *maintenanceCoordinator - hub JobBroadcaster - logger *logger.Logger - secretStore secrets.SecretStore + repo store.Repository + discovery *discovery.Registry + agentRegistry *registry.Registry + sessionChecker SessionChecker + watcherDeps WatcherDependencyChecker + routingTierDeps RoutingTierDependencyChecker + automationDeps AutomationDependencyChecker + utilityDeps UtilityDependencyChecker + mcpService *mcpconfig.Service + modelCache *modelfetcher.Cache + hostUtility hostUtilityProvider + jobStore *JobStore + updateJobStore *AgentUpdateJobStore + runtimeUpdater RuntimeUpdater + managedRuntimeSelections managedruntime.SelectionStore + maintenance *maintenanceCoordinator + hub JobBroadcaster + logger *logger.Logger + secretStore secrets.SecretStore + dynamicAgentRoutingEnabled bool +} + +// SetDynamicAgentRoutingEnabled applies the authoritative runtime flag to the +// settings boundary. Dynamic configuration remains readable when disabled, but +// all dynamic writes fail before they create or alter rows. +func (c *Controller) SetDynamicAgentRoutingEnabled(enabled bool) { + c.dynamicAgentRoutingEnabled = enabled } // SetSecretStore wires the metadata-only validator used by shared agent @@ -113,17 +128,18 @@ func (c *Controller) SetUtilityDependencyChecker(u UtilityDependencyChecker) { // the breakdown to render a "this will also disable N watchers — continue?" // confirmation dialog before re-issuing the request with force=true. type ErrProfileInUseDetail struct { - ActiveSessions []agentdto.ActiveTaskInfo - Watchers []WatcherReference - RoutingTiers []RoutingTierReference - Automations []AutomationReference - UtilityAgents []UtilityAgentReference + ActiveSessions []agentdto.ActiveTaskInfo + Watchers []WatcherReference + RoutingTiers []RoutingTierReference + Automations []AutomationReference + UtilityAgents []UtilityAgentReference + DynamicProfiles []DynamicProfileReference } func (e *ErrProfileInUseDetail) Error() string { return fmt.Sprintf( - "agent profile is used by %d active session(s), %d watcher(s), %d routing tier(s), %d automation(s), and %d utility agent(s)", - len(e.ActiveSessions), len(e.Watchers), len(e.RoutingTiers), len(e.Automations), len(e.UtilityAgents)) + "agent profile is used by %d active session(s), %d watcher(s), %d routing tier(s), %d automation(s), %d utility agent(s), and %d dynamic profile(s)", + len(e.ActiveSessions), len(e.Watchers), len(e.RoutingTiers), len(e.Automations), len(e.UtilityAgents), len(e.DynamicProfiles)) } type UtilityAgentReference struct { @@ -131,6 +147,15 @@ type UtilityAgentReference struct { Name string `json:"name"` } +// DynamicProfileReference identifies a dynamic profile that contains the +// concrete profile as a candidate. The route remains stored after a forced +// disable or delete so the router can skip it and the user can repair it. +type DynamicProfileReference struct { + ID string `json:"id"` + Name string `json:"name"` + Deleted bool `json:"deleted"` +} + type UtilityDependencyChecker interface { ListUtilityAgentsByAgentProfile(context.Context, string) ([]UtilityAgentReference, error) ClearUtilityAgentProfileBindings(context.Context, string) error diff --git a/apps/backend/internal/agent/settings/controller/controller_test.go b/apps/backend/internal/agent/settings/controller/controller_test.go index 5694c08685..123797ec96 100644 --- a/apps/backend/internal/agent/settings/controller/controller_test.go +++ b/apps/backend/internal/agent/settings/controller/controller_test.go @@ -582,13 +582,15 @@ func TestSyncAgentFromDiscovery_UnknownAgentSkipped(t *testing.T) { // TestDetectAgents_E2EMockBypassesFilesystem verifies that when // KANDEV_E2E_MOCK=true, detectAgents returns results synthesised from the // registry without calling c.discovery.Detect (which would panic here -// because c.discovery is nil). Every enabled agent must appear as Available. +// because c.discovery is nil). Every enabled inference agent must appear as +// Available, while virtual families remain outside host discovery. func TestDetectAgents_E2EMockBypassesFilesystem(t *testing.T) { t.Setenv("KANDEV_E2E_MOCK", "true") ctrl := newTestController(map[string]agents.Agent{ - "mock-agent": &testAgent{id: "mock-agent", name: "mock-agent", enabled: true}, - "other-mock": &testAgent{id: "other-mock", name: "other-mock", enabled: true}, + "mock-agent": &testAgent{id: "mock-agent", name: "mock-agent", enabled: true}, + "other-mock": &testAgent{id: "other-mock", name: "other-mock", enabled: true}, + agents.DynamicAgentID: agents.NewDynamicAgent(), }) // c.discovery is nil — if detectAgents called it the test would panic. @@ -597,9 +599,12 @@ func TestDetectAgents_E2EMockBypassesFilesystem(t *testing.T) { t.Fatalf("detectAgents() error = %v", err) } if len(results) != 2 { - t.Fatalf("expected 2 results, got %d", len(results)) + t.Fatalf("expected 2 inference results, got %d", len(results)) } for _, r := range results { + if r.Name == agents.DynamicAgentID { + t.Fatal("virtual Dynamic family must not appear in host discovery") + } if !r.Available { t.Errorf("agent %q: Available = false, want true in E2E mock mode", r.Name) } diff --git a/apps/backend/internal/agent/settings/controller/dynamic_policy.go b/apps/backend/internal/agent/settings/controller/dynamic_policy.go new file mode 100644 index 0000000000..d8ff0978ba --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/dynamic_policy.go @@ -0,0 +1,212 @@ +package controller + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/kandev/kandev/internal/agent/runtime/routingerr" + "github.com/kandev/kandev/internal/agent/settings/dto" +) + +const ( + dynamicPolicyVersion int64 = 1 + + dynamicPolicyOutcomeSkip = "skip" + dynamicPolicyOutcomeStop = "stop" + + dynamicPolicyMaxRetries int64 = 10 + dynamicPolicyMaxInitialIntervalSecs int64 = 3600 + dynamicPolicyMaxWaitSecs int64 = 7 * 24 * 60 * 60 +) + +func defaultDynamicPolicyDocument() dto.DynamicAgentPolicyDTO { + return dto.DynamicAgentPolicyDTO{ + Version: dynamicPolicyVersion, + Transient: defaultDynamicErrorPolicy(), + Hard: defaultDynamicErrorPolicy(), + } +} + +func defaultDynamicErrorPolicy() dto.DynamicErrorPolicyDTO { + return dto.DynamicErrorPolicyDTO{OnExhausted: dynamicPolicyOutcomeSkip} +} + +func legacyDynamicErrorPolicy(action string) dto.DynamicErrorPolicyDTO { + policy := defaultDynamicErrorPolicy() + switch action { + case dynamicRouteActionRetrySame: + policy.Retry = dto.DynamicRetryPolicyDTO{ + Enabled: true, + MaxRetries: 1, + InitialIntervalSeconds: 5, + } + policy.OnExhausted = dynamicPolicyOutcomeStop + case dynamicRouteActionStop: + policy.OnExhausted = dynamicPolicyOutcomeStop + } + return policy +} + +func normalizeDynamicCandidatePolicy(candidate *dto.DynamicAgentCandidateDTO) error { + if candidate.Policies != nil { + return normalizeCanonicalDynamicPolicy(candidate) + } + + policy, err := normalizeLegacyDynamicPolicy(candidate) + if err != nil { + return err + } + if err := validateDynamicPolicyDocument(policy, candidate.Position); err != nil { + return err + } + candidate.Policies = &policy + candidate.Rules = nil + return nil +} + +func normalizeCanonicalDynamicPolicy(candidate *dto.DynamicAgentCandidateDTO) error { + if len(candidate.Rules) > 0 { + return fmt.Errorf("%w: candidates[%d].policies cannot be combined with legacy rules", ErrDynamicProfileRule, candidate.Position) + } + policy := *candidate.Policies + if err := validateDynamicPolicyDocument(policy, candidate.Position); err != nil { + return err + } + candidate.Policies = &policy + candidate.Rules = nil + return nil +} + +func normalizeLegacyDynamicPolicy(candidate *dto.DynamicAgentCandidateDTO) (dto.DynamicAgentPolicyDTO, error) { + policy := defaultDynamicPolicyDocument() + classActions := map[routingerr.Class]string{} + keys := make([]string, 0, len(candidate.Rules)) + for key := range candidate.Rules { + keys = append(keys, key) + } + sort.Strings(keys) + for _, rawKey := range keys { + if err := applyLegacyDynamicRule(&policy, classActions, candidate.Position, rawKey, candidate.Rules[rawKey]); err != nil { + return dto.DynamicAgentPolicyDTO{}, err + } + } + return policy, nil +} + +func applyLegacyDynamicRule( + policy *dto.DynamicAgentPolicyDTO, + classActions map[routingerr.Class]string, + position int, + rawKey string, + rawAction string, +) error { + key := strings.TrimSpace(rawKey) + action := strings.TrimSpace(rawAction) + if key == "" { + return fmt.Errorf("%w: candidates[%d].rules has an empty key", ErrDynamicProfileRule, position) + } + if !isDynamicRouteAction(action) { + return fmt.Errorf("%w: candidates[%d].rules.%s=%s", ErrDynamicProfileRule, position, key, action) + } + if key == "on_provider_error" { + legacyPolicy := legacyDynamicErrorPolicy(action) + policy.Transient = legacyPolicy + policy.Hard = legacyPolicy + return nil + } + class := routingerr.ClassForCode(routingerr.Code(key)) + if class != routingerr.ClassTransient && class != routingerr.ClassHard { + return fmt.Errorf("%w: candidates[%d].rules.%s is not a provider error code", ErrDynamicProfileRule, position, key) + } + if previous, ok := classActions[class]; ok && previous != action { + return fmt.Errorf("%w: candidates[%d].rules has conflicting actions for %s errors", ErrDynamicProfileRule, position, class) + } + classActions[class] = action + legacyPolicy := legacyDynamicErrorPolicy(action) + if class == routingerr.ClassTransient { + policy.Transient = legacyPolicy + } else { + policy.Hard = legacyPolicy + } + return nil +} + +func validateDynamicPolicyDocument(policy dto.DynamicAgentPolicyDTO, position int) error { + if policy.Version != dynamicPolicyVersion { + return fmt.Errorf("%w: candidates[%d].policies.version=%d, supported version is %d", ErrDynamicProfileRule, position, policy.Version, dynamicPolicyVersion) + } + if err := validateDynamicErrorPolicy(policy.Transient, position, "transient"); err != nil { + return err + } + if err := validateDynamicErrorPolicy(policy.Hard, position, "hard"); err != nil { + return err + } + return nil +} + +func validateDynamicErrorPolicy(policy dto.DynamicErrorPolicyDTO, position int, class string) error { + field := fmt.Sprintf("candidates[%d].policies.%s", position, class) + if policy.OnExhausted != dynamicPolicyOutcomeSkip && policy.OnExhausted != dynamicPolicyOutcomeStop { + return fmt.Errorf("%w: %s.on_exhausted=%q must be skip or stop", ErrDynamicProfileRule, field, policy.OnExhausted) + } + if policy.Retry.Enabled { + if policy.Retry.MaxRetries < 1 || policy.Retry.MaxRetries > dynamicPolicyMaxRetries { + return fmt.Errorf("%w: %s.retry.max_retries must be between 1 and %d", ErrDynamicProfileRule, field, dynamicPolicyMaxRetries) + } + if policy.Retry.InitialIntervalSeconds < 1 || policy.Retry.InitialIntervalSeconds > dynamicPolicyMaxInitialIntervalSecs { + return fmt.Errorf("%w: %s.retry.initial_interval_seconds must be between 1 and %d", ErrDynamicProfileRule, field, dynamicPolicyMaxInitialIntervalSecs) + } + } else if policy.Retry.MaxRetries != 0 || policy.Retry.InitialIntervalSeconds != 0 { + return fmt.Errorf("%w: %s.retry disabled values must be zero", ErrDynamicProfileRule, field) + } + if policy.WaitForReset.Enabled { + if policy.WaitForReset.MaxWaitSeconds < 1 || policy.WaitForReset.MaxWaitSeconds > dynamicPolicyMaxWaitSecs { + return fmt.Errorf("%w: %s.wait_for_reset.max_wait_seconds must be between 1 and %d", ErrDynamicProfileRule, field, dynamicPolicyMaxWaitSecs) + } + } else if policy.WaitForReset.MaxWaitSeconds != 0 { + return fmt.Errorf("%w: %s.wait_for_reset disabled value must be zero", ErrDynamicProfileRule, field) + } + return nil +} + +func isDynamicRouteAction(action string) bool { + switch action { + case dynamicRouteActionRetrySame, dynamicRouteActionTryNext, dynamicRouteActionStop: + return true + default: + return false + } +} + +func decodeDynamicPolicyDocument(raw string, position int) (dto.DynamicAgentPolicyDTO, error) { + if strings.TrimSpace(raw) == "" { + return defaultDynamicPolicyDocument(), nil + } + + var fields map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &fields); err != nil { + return dto.DynamicAgentPolicyDTO{}, fmt.Errorf("decode dynamic route policy: %w", err) + } + if _, hasVersion := fields["version"]; hasVersion { + var policy dto.DynamicAgentPolicyDTO + if err := json.Unmarshal([]byte(raw), &policy); err != nil { + return dto.DynamicAgentPolicyDTO{}, fmt.Errorf("decode dynamic route policy: %w", err) + } + if err := validateDynamicPolicyDocument(policy, position); err != nil { + return dto.DynamicAgentPolicyDTO{}, err + } + return policy, nil + } + + var rules map[string]string + if err := json.Unmarshal([]byte(raw), &rules); err != nil { + return dto.DynamicAgentPolicyDTO{}, fmt.Errorf("decode legacy dynamic route rules: %w", err) + } + candidate := dto.DynamicAgentCandidateDTO{Position: position, Rules: rules} + if err := normalizeDynamicCandidatePolicy(&candidate); err != nil { + return dto.DynamicAgentPolicyDTO{}, err + } + return *candidate.Policies, nil +} diff --git a/apps/backend/internal/agent/settings/controller/dynamic_profile_test.go b/apps/backend/internal/agent/settings/controller/dynamic_profile_test.go new file mode 100644 index 0000000000..108fdb65e0 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/dynamic_profile_test.go @@ -0,0 +1,312 @@ +package controller + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/settings/dto" + "github.com/kandev/kandev/internal/agent/settings/models" +) + +func TestValidateDynamicAgentProfile(t *testing.T) { + validPolicy := func() *dto.DynamicAgentPolicyDTO { + return &dto.DynamicAgentPolicyDTO{ + Version: 1, + Transient: dto.DynamicErrorPolicyDTO{ + Retry: dto.DynamicRetryPolicyDTO{Enabled: true, MaxRetries: 2, InitialIntervalSeconds: 5}, + WaitForReset: dto.DynamicResetWaitPolicyDTO{Enabled: true, MaxWaitSeconds: 300}, + OnExhausted: "skip", + }, + Hard: dto.DynamicErrorPolicyDTO{ + OnExhausted: "stop", + }, + } + } + tests := []struct { + name string + profile *dto.DynamicAgentProfileDTO + wantErr error + }{ + { + name: "missing candidates", + profile: &dto.DynamicAgentProfileDTO{}, + wantErr: ErrDynamicProfileCandidatesRequired, + }, + { + name: "positions must be ordered", + profile: &dto.DynamicAgentProfileDTO{Candidates: []dto.DynamicAgentCandidateDTO{ + {Position: 0, ExecutionProfileID: "a"}, + {Position: 0, ExecutionProfileID: "b"}, + }}, + wantErr: ErrDynamicProfilePositions, + }, + { + name: "explicit fallback model policy is allowed", + profile: &dto.DynamicAgentProfileDTO{Candidates: []dto.DynamicAgentCandidateDTO{ + {Position: 0, ExecutionProfileID: "a", Rules: map[string]string{"on_provider_error": "try_next"}}, + }}, + }, + { + name: "unsupported action is rejected", + profile: &dto.DynamicAgentProfileDTO{Candidates: []dto.DynamicAgentCandidateDTO{ + {Position: 0, ExecutionProfileID: "a", Rules: map[string]string{"on_provider_error": "teleport"}}, + }}, + wantErr: ErrDynamicProfileRule, + }, + { + name: "typed policy accepts bounded class settings", + profile: &dto.DynamicAgentProfileDTO{Candidates: []dto.DynamicAgentCandidateDTO{ + {Position: 0, ExecutionProfileID: "a", Policies: validPolicy()}, + }}, + }, + { + name: "typed policy rejects an incomplete class document", + profile: &dto.DynamicAgentProfileDTO{Candidates: []dto.DynamicAgentCandidateDTO{ + {Position: 0, ExecutionProfileID: "a", Policies: &dto.DynamicAgentPolicyDTO{ + Version: 1, + Transient: dto.DynamicErrorPolicyDTO{OnExhausted: "skip"}, + }}, + }}, + wantErr: ErrDynamicProfileRule, + }, + { + name: "typed policy rejects out of range retry", + profile: &dto.DynamicAgentProfileDTO{Candidates: []dto.DynamicAgentCandidateDTO{ + {Position: 0, ExecutionProfileID: "a", Policies: &dto.DynamicAgentPolicyDTO{ + Version: 1, + Transient: dto.DynamicErrorPolicyDTO{ + Retry: dto.DynamicRetryPolicyDTO{Enabled: true, MaxRetries: 11, InitialIntervalSeconds: 5}, + OnExhausted: "skip", + }, + Hard: dto.DynamicErrorPolicyDTO{OnExhausted: "skip"}, + }}, + }}, + wantErr: ErrDynamicProfileRule, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDynamicAgentProfile(tt.profile) + if tt.wantErr == nil { + if err != nil { + t.Fatalf("validateDynamicAgentProfile: %v", err) + } + return + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("error = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestNormalizeDynamicPolicyLegacyRules(t *testing.T) { + tests := []struct { + name string + rules map[string]string + check func(t *testing.T, policy dto.DynamicAgentPolicyDTO) + }{ + { + name: "try next defaults both classes to skip", + rules: map[string]string{"on_provider_error": "try_next"}, + check: func(t *testing.T, policy dto.DynamicAgentPolicyDTO) { + if policy.Transient.OnExhausted != "skip" || policy.Hard.OnExhausted != "skip" { + t.Fatalf("policy = %#v", policy) + } + }, + }, + { + name: "retry same maps to one delayed retry", + rules: map[string]string{"on_provider_error": "retry_same"}, + check: func(t *testing.T, policy dto.DynamicAgentPolicyDTO) { + if !policy.Transient.Retry.Enabled || policy.Transient.Retry.MaxRetries != 1 || policy.Transient.Retry.InitialIntervalSeconds != 5 { + t.Fatalf("policy = %#v", policy.Transient) + } + if policy.Hard.Retry != policy.Transient.Retry || policy.Hard.OnExhausted != "stop" { + t.Fatalf("policy = %#v", policy) + } + }, + }, + { + name: "specific class rule overrides generic default", + rules: map[string]string{"on_provider_error": "try_next", "quota_limited": "stop"}, + check: func(t *testing.T, policy dto.DynamicAgentPolicyDTO) { + if policy.Transient.OnExhausted != "skip" || policy.Hard.OnExhausted != "stop" { + t.Fatalf("policy = %#v", policy) + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + profile := &dto.DynamicAgentProfileDTO{Candidates: []dto.DynamicAgentCandidateDTO{{ + Position: 0, ExecutionProfileID: "candidate", Rules: tt.rules, + }}} + if err := validateDynamicAgentProfile(profile); err != nil { + t.Fatalf("validateDynamicAgentProfile: %v", err) + } + if profile.Candidates[0].Policies == nil { + t.Fatal("legacy rules were not normalized") + } + tt.check(t, *profile.Candidates[0].Policies) + }) + } +} + +func TestNormalizeDynamicPolicyRejectsConflictingClassRules(t *testing.T) { + profile := &dto.DynamicAgentProfileDTO{Candidates: []dto.DynamicAgentCandidateDTO{{ + Position: 0, + ExecutionProfileID: "candidate", + Rules: map[string]string{ + "rate_limited": "try_next", + "network_unavailable": "stop", + }, + }}} + if err := validateDynamicAgentProfile(profile); !errors.Is(err, ErrDynamicProfileRule) { + t.Fatalf("error = %v, want conflicting class rule", err) + } +} + +func TestDynamicPolicyCanonicalRoundTrip(t *testing.T) { + profile := &dto.DynamicAgentProfileDTO{Candidates: []dto.DynamicAgentCandidateDTO{{ + Position: 0, + ExecutionProfileID: "candidate", + Rules: map[string]string{"on_provider_error": "try_next"}, + }}} + if err := validateDynamicAgentProfile(profile); err != nil { + t.Fatalf("validateDynamicAgentProfile: %v", err) + } + routes, err := dynamicRoutesFromDTO("dynamic", profile) + if err != nil { + t.Fatalf("dynamicRoutesFromDTO: %v", err) + } + got, err := dynamicProfileDTO(&models.DynamicAgentProfile{ProfileID: "dynamic", Version: 1}, routes) + if err != nil { + t.Fatalf("dynamicProfileDTO: %v", err) + } + if got.Candidates[0].Rules != nil { + t.Fatalf("canonical response retained legacy rules: %#v", got.Candidates[0].Rules) + } + if !reflect.DeepEqual(got.Candidates[0].Policies, profile.Candidates[0].Policies) { + t.Fatalf("policies = %#v, want %#v", got.Candidates[0].Policies, profile.Candidates[0].Policies) + } +} + +func TestDynamicProfileCreateAndUpdatePersistsCandidates(t *testing.T) { + ctrl, repo := newSQLiteBackedController(t) + if err := ctrl.agentRegistry.Register(agents.NewDynamicAgent()); err != nil { + t.Fatalf("register dynamic agent: %v", err) + } + if err := ctrl.agentRegistry.Register(agents.NewClaudeACP()); err != nil { + t.Fatalf("register concrete agent: %v", err) + } + ctrl.SetDynamicAgentRoutingEnabled(true) + ctx := context.Background() + if err := repo.CreateAgent(ctx, &models.Agent{ID: agents.DynamicAgentID, Name: agents.DynamicAgentID}); err != nil { + t.Fatalf("create dynamic family: %v", err) + } + if err := repo.CreateAgent(ctx, &models.Agent{ID: "claude-family", Name: "claude-acp"}); err != nil { + t.Fatalf("create concrete family: %v", err) + } + candidate := &models.AgentProfile{AgentID: "claude-family", Name: "Claude", AgentDisplayName: "Claude"} + if err := repo.CreateAgentProfile(ctx, candidate); err != nil { + t.Fatalf("create candidate: %v", err) + } + + created, err := ctrl.CreateProfile(ctx, CreateProfileRequest{ + AgentID: agents.DynamicAgentID, + Name: "Balanced", + Dynamic: &dto.DynamicAgentProfileDTO{Candidates: []dto.DynamicAgentCandidateDTO{{ + Position: 0, + ExecutionProfileID: candidate.ID, + Enabled: true, + Rules: map[string]string{"on_provider_error": "try_next"}, + }}}, + }) + if err != nil { + t.Fatalf("create dynamic profile: %v", err) + } + if created.Kind != "dynamic" || created.Dynamic == nil || created.Dynamic.Version != 1 { + t.Fatalf("created dynamic profile = %#v", created) + } + + updated, err := ctrl.UpdateProfile(ctx, UpdateProfileRequest{ + ID: created.ID, + Dynamic: &dto.DynamicAgentProfileDTO{ + Version: 1, + Candidates: []dto.DynamicAgentCandidateDTO{{ + Position: 0, + ExecutionProfileID: candidate.ID, + Enabled: false, + }}, + }, + }) + if err != nil { + t.Fatalf("update dynamic profile: %v", err) + } + if updated.Dynamic == nil || updated.Dynamic.Version != 2 || updated.Dynamic.Candidates[0].Enabled { + t.Fatalf("updated dynamic profile = %#v", updated) + } + staleName := "Stale overwrite" + if _, err := ctrl.UpdateProfile(ctx, UpdateProfileRequest{ + ID: created.ID, + Name: &staleName, + Dynamic: &dto.DynamicAgentProfileDTO{ + Version: 1, + Candidates: updated.Dynamic.Candidates, + }, + }); !errors.Is(err, ErrDynamicProfileVersionConflict) { + t.Fatalf("stale update error = %v, want version conflict", err) + } + stored, err := repo.GetAgentProfile(ctx, created.ID) + if err != nil { + t.Fatalf("read dynamic profile after stale update: %v", err) + } + if stored.Name != "Balanced" { + t.Fatalf("stale update changed base profile name to %q", stored.Name) + } + + ctrl.SetDynamicAgentRoutingEnabled(false) + disabledName := "Disabled overwrite" + if _, err := ctrl.UpdateProfile(ctx, UpdateProfileRequest{ID: created.ID, Name: &disabledName}); !errors.Is(err, ErrDynamicAgentRoutingDisabled) { + t.Fatalf("disabled dynamic update error = %v, want %v", err, ErrDynamicAgentRoutingDisabled) + } + if _, err := ctrl.DeleteProfile(ctx, created.ID, false); !errors.Is(err, ErrDynamicAgentRoutingDisabled) { + t.Fatalf("disabled dynamic delete error = %v, want %v", err, ErrDynamicAgentRoutingDisabled) + } +} + +func TestDynamicProfileCreateValidatesBeforeCreatingParent(t *testing.T) { + ctrl, repo := newSQLiteBackedController(t) + if err := ctrl.agentRegistry.Register(agents.NewDynamicAgent()); err != nil { + t.Fatalf("register dynamic agent: %v", err) + } + ctrl.SetDynamicAgentRoutingEnabled(true) + ctx := context.Background() + if err := repo.CreateAgent(ctx, &models.Agent{ID: agents.DynamicAgentID, Name: agents.DynamicAgentID}); err != nil { + t.Fatalf("create dynamic family: %v", err) + } + + _, err := ctrl.CreateProfile(ctx, CreateProfileRequest{ + AgentID: agents.DynamicAgentID, + Name: "Invalid", + Dynamic: &dto.DynamicAgentProfileDTO{Candidates: []dto.DynamicAgentCandidateDTO{{ + Position: 0, + ExecutionProfileID: "missing-profile", + Enabled: true, + }}}, + }) + if !errors.Is(err, ErrDynamicProfileCandidate) { + t.Fatalf("create error = %v, want invalid candidate", err) + } + profiles, err := repo.ListAgentProfiles(ctx, agents.DynamicAgentID) + if err != nil { + t.Fatalf("list dynamic profiles: %v", err) + } + if len(profiles) != 0 { + t.Fatalf("created %d parent rows after validation failure, want 0", len(profiles)) + } +} diff --git a/apps/backend/internal/agent/settings/controller/profile_crud.go b/apps/backend/internal/agent/settings/controller/profile_crud.go index 456357b7ce..ad2eb3a31b 100644 --- a/apps/backend/internal/agent/settings/controller/profile_crud.go +++ b/apps/backend/internal/agent/settings/controller/profile_crud.go @@ -3,12 +3,14 @@ package controller import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "regexp" "sort" "strings" + "github.com/google/uuid" "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/agents" @@ -20,6 +22,8 @@ import ( "github.com/kandev/kandev/internal/secrets" ) +const dynamicProfileKind = "dynamic" + type CreateProfileRequest struct { AgentID string Name string @@ -40,6 +44,7 @@ type CreateProfileRequest struct { // CommandPrefix is an optional launcher prefix prepended to the agent // command (e.g. "greywall --"). Shell-tokenised at launch time. CommandPrefix string + Dynamic *dto.DynamicAgentProfileDTO } func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest) (*dto.AgentProfileDTO, error) { @@ -58,6 +63,9 @@ func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest if err != nil { return nil, err } + if agent.Name == agents.DynamicAgentID { + return c.createDynamicProfile(ctx, agent, displayName, req) + } cliFlags := cliFlagsFromDTO(req.CLIFlags) if req.CLIFlags == nil { cliFlags = seedCLIFlags(agentConfig) @@ -98,6 +106,110 @@ func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest return &result, nil } +func (c *Controller) createDynamicProfile( + ctx context.Context, + agent *models.Agent, + displayName string, + req CreateProfileRequest, +) (*dto.AgentProfileDTO, error) { + if !c.dynamicAgentRoutingEnabled { + return nil, ErrDynamicAgentRoutingDisabled + } + dynamicRepo, err := c.dynamicProfileRepository() + if err != nil { + return nil, err + } + if err := validateDynamicAgentProfile(req.Dynamic); err != nil { + return nil, err + } + profile := &models.AgentProfile{ + ID: uuid.NewString(), + AgentID: agent.ID, + Name: strings.TrimSpace(req.Name), + AgentDisplayName: displayName, + Enabled: true, + CLIFlags: []models.CLIFlag{}, + EnvVars: []models.ProfileEnvVar{}, + UserModified: true, + } + routes, err := c.validateDynamicCandidates(ctx, profile.ID, req.Dynamic) + if err != nil { + return nil, err + } + if err := c.repo.CreateAgentProfile(ctx, profile); err != nil { + return nil, err + } + dynamic := &models.DynamicAgentProfile{ProfileID: profile.ID, Version: 1} + if err := dynamicRepo.CreateDynamicAgentProfile(ctx, dynamic, routes); err != nil { + if cleanupErr := c.repo.DeleteAgentProfile(ctx, profile.ID); cleanupErr != nil { + return nil, fmt.Errorf("%w; cleanup dynamic profile parent: %v", err, cleanupErr) + } + return nil, err + } + result := toProfileDTO(profile) + result.Dynamic, err = dynamicProfileDTO(dynamic, routes) + if err != nil { + return nil, err + } + return &result, nil +} + +func (c *Controller) dynamicProfileRepository() (store.DynamicProfileRepository, error) { + dynamicRepo, ok := c.repo.(store.DynamicProfileRepository) + if !ok { + return nil, fmt.Errorf("dynamic profile store is unavailable") + } + return dynamicRepo, nil +} + +func (c *Controller) validateDynamicCandidates( + ctx context.Context, + profileID string, + dynamic *dto.DynamicAgentProfileDTO, +) ([]models.DynamicAgentRoute, error) { + routes, err := dynamicRoutesFromDTO(profileID, dynamic) + if err != nil { + return nil, err + } + seen := make(map[string]struct{}, len(routes)) + for _, route := range routes { + if route.ExecutionProfileID == profileID { + return nil, fmt.Errorf("%w: a profile cannot reference itself", ErrDynamicProfileCandidate) + } + if _, ok := seen[route.ExecutionProfileID]; ok { + return nil, fmt.Errorf("%w: duplicate execution profile %q", ErrDynamicProfileCandidate, route.ExecutionProfileID) + } + seen[route.ExecutionProfileID] = struct{}{} + candidate, err := c.repo.GetAgentProfileIncludingDeleted(ctx, route.ExecutionProfileID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: profile %q was not found", ErrDynamicProfileCandidate, route.ExecutionProfileID) + } + return nil, err + } + if candidate.DeletedAt != nil { + return nil, fmt.Errorf("%w: profile %q is deleted", ErrDynamicProfileCandidate, route.ExecutionProfileID) + } + candidateAgent, err := c.repo.GetAgent(ctx, candidate.AgentID) + if err != nil { + return nil, fmt.Errorf("%w: profile %q has no agent family", ErrDynamicProfileCandidate, route.ExecutionProfileID) + } + if candidate.AgentID == agents.DynamicAgentID || candidateAgent.Name == agents.DynamicAgentID { + return nil, fmt.Errorf("%w: dynamic profiles cannot be candidates", ErrDynamicProfileCandidate) + } + if candidate.WorkspaceID != "" || candidate.Role != "" { + return nil, fmt.Errorf("%w: rich Office profiles cannot be candidates", ErrDynamicProfileCandidate) + } + if candidate.AutoFallback { + return nil, fmt.Errorf("%w: AutoFallback profiles cannot be candidates", ErrDynamicProfileCandidate) + } + if _, ok := c.agentRegistry.GetInferenceAgent(candidateAgent.Name); !ok { + return nil, fmt.Errorf("%w: profile %q is not launchable", ErrDynamicProfileCandidate, route.ExecutionProfileID) + } + } + return routes, nil +} + // seedCLIFlags builds the default cli_flags list for a new profile from the // agent's curated PermissionSettings() catalogue. Only entries that target a // CLI flag are included; per-flag metadata (description, flag text, default @@ -137,6 +249,77 @@ func firstNonEmpty(a, b string) string { return b } +const ( + dynamicRouteActionRetrySame = "retry_same" + dynamicRouteActionTryNext = "try_next" + dynamicRouteActionStop = "stop" +) + +func validateDynamicAgentProfile(profile *dto.DynamicAgentProfileDTO) error { + if profile == nil || len(profile.Candidates) == 0 { + return ErrDynamicProfileCandidatesRequired + } + for position, candidate := range profile.Candidates { + if candidate.Position != position { + return fmt.Errorf("%w: candidate %d has position %d", ErrDynamicProfilePositions, position, candidate.Position) + } + if strings.TrimSpace(candidate.ExecutionProfileID) == "" { + return fmt.Errorf("%w: candidate %d has no execution profile", ErrDynamicProfileCandidate, position) + } + if err := normalizeDynamicCandidatePolicy(&profile.Candidates[position]); err != nil { + return err + } + } + return nil +} + +func dynamicRoutesFromDTO(profileID string, profile *dto.DynamicAgentProfileDTO) ([]models.DynamicAgentRoute, error) { + if err := validateDynamicAgentProfile(profile); err != nil { + return nil, err + } + routes := make([]models.DynamicAgentRoute, 0, len(profile.Candidates)) + for _, candidate := range profile.Candidates { + if candidate.Policies == nil { + return nil, fmt.Errorf("%w: candidate %d has no normalized policy", ErrDynamicProfileRule, candidate.Position) + } + policyJSON, err := json.Marshal(candidate.Policies) + if err != nil { + return nil, fmt.Errorf("marshal dynamic route policy: %w", err) + } + routes = append(routes, models.DynamicAgentRoute{ + DynamicProfileID: profileID, + Position: candidate.Position, + ExecutionProfileID: strings.TrimSpace(candidate.ExecutionProfileID), + Enabled: candidate.Enabled, + RulesJSON: string(policyJSON), + }) + } + return routes, nil +} + +func dynamicProfileDTO(profile *models.DynamicAgentProfile, routes []models.DynamicAgentRoute) (*dto.DynamicAgentProfileDTO, error) { + if profile == nil { + return nil, nil + } + result := &dto.DynamicAgentProfileDTO{ + Version: profile.Version, + Candidates: make([]dto.DynamicAgentCandidateDTO, 0, len(routes)), + } + for _, route := range routes { + policy, err := decodeDynamicPolicyDocument(route.RulesJSON, route.Position) + if err != nil { + return nil, err + } + result.Candidates = append(result.Candidates, dto.DynamicAgentCandidateDTO{ + Position: route.Position, + ExecutionProfileID: route.ExecutionProfileID, + Enabled: route.Enabled, + Policies: &policy, + }) + } + return result, nil +} + type UpdateProfileRequest struct { ID string Name *string @@ -158,6 +341,7 @@ type UpdateProfileRequest struct { // CommandPrefix replaces the value when non-nil. Nil means "leave // unchanged" — the UI always sends the desired value on save. CommandPrefix *string + Dynamic *dto.DynamicAgentProfileDTO Force bool } @@ -166,7 +350,7 @@ func enabledOnlyUpdate(req UpdateProfileRequest) bool { req.FallbackModel == nil && req.AutoFallback == nil && req.Mode == nil && req.ConfigOptions == nil && req.AllowIndexing == nil && req.AutoApprove == nil && req.CLIPassthrough == nil && req.CLIFlags == nil && req.EnvVars == nil && - req.CommandPrefix == nil + req.CommandPrefix == nil && req.Dynamic == nil } func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest) (*dto.AgentProfileDTO, error) { @@ -174,6 +358,29 @@ func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest if err != nil { return nil, ErrAgentProfileNotFound } + isDynamic := profileKind(profile) == dynamicProfileKind + if isDynamic && !c.dynamicAgentRoutingEnabled { + return nil, ErrDynamicAgentRoutingDisabled + } + var ( + dynamicRepo store.DynamicProfileRepository + dynamic *models.DynamicAgentProfile + dynamicRoutes []models.DynamicAgentRoute + ) + if isDynamic && req.Dynamic != nil { + if req.Dynamic.Version <= 0 { + return nil, fmt.Errorf("dynamic profile version is required") + } + dynamicRoutes, err = c.validateDynamicCandidates(ctx, profile.ID, req.Dynamic) + if err != nil { + return nil, err + } + dynamicRepo, err = c.dynamicProfileRepository() + if err != nil { + return nil, err + } + dynamic = &models.DynamicAgentProfile{ProfileID: profile.ID} + } if req.Name != nil { profile.Name = *req.Name } @@ -202,6 +409,13 @@ func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest profile.CLIPassthrough = *req.CLIPassthrough } if req.Enabled != nil { + dynamicRefs, err := c.listDynamicProfileReferences(ctx, req.ID) + if err != nil { + return nil, err + } + if !*req.Enabled && !req.Force && len(dynamicRefs) > 0 { + return nil, &ErrProfileInUseDetail{DynamicProfiles: dynamicRefs} + } if !*req.Enabled && !req.Force && c.utilityDeps != nil { refs, err := c.utilityDeps.ListUtilityAgentsByAgentProfile(ctx, req.ID) if err != nil { @@ -245,13 +459,58 @@ func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest profile.CommandPrefix = strings.TrimSpace(*req.CommandPrefix) } profile.UserModified = true + if dynamic != nil { + result, handled, atomicErr := c.updateDynamicProfileAtomically( + ctx, profile, dynamic, req.Dynamic.Version, dynamicRoutes, + ) + if atomicErr != nil { + return nil, atomicErr + } + if handled { + return result, nil + } + } + if dynamicRepo != nil && dynamic != nil { + if err := dynamicRepo.UpdateDynamicAgentProfile(ctx, dynamic, req.Dynamic.Version, dynamicRoutes); err != nil { + return nil, err + } + } if err := c.repo.UpdateAgentProfile(ctx, profile); err != nil { return nil, err } result := toProfileDTO(profile) + if dynamicRepo != nil && dynamic != nil { + result.Dynamic, err = dynamicProfileDTO(dynamic, dynamicRoutes) + if err != nil { + return nil, err + } + } return &result, nil } +func (c *Controller) updateDynamicProfileAtomically( + ctx context.Context, + profile *models.AgentProfile, + dynamic *models.DynamicAgentProfile, + version int64, + routes []models.DynamicAgentRoute, +) (*dto.AgentProfileDTO, bool, error) { + atomicRepo, ok := c.repo.(store.AtomicDynamicProfileRepository) + if !ok { + return nil, false, nil + } + if err := atomicRepo.UpdateAgentProfileWithDynamic(ctx, profile, dynamic, version, routes); err != nil { + return nil, true, err + } + result := toProfileDTO(profile) + var err error + result.Dynamic, err = dynamicProfileDTO(dynamic, routes) + if err != nil { + return nil, true, err + } + return &result, true, nil +} + type DuplicateProfileRequest struct { ID string } @@ -290,6 +549,9 @@ func (c *Controller) DuplicateProfile(ctx context.Context, req DuplicateProfileR if source.WorkspaceID != "" { return nil, ErrAgentProfileNotFound } + if profileKind(source) == dynamicProfileKind { + return nil, ErrDynamicProfileDuplicationUnsupported + } for attempt := 0; ; attempt++ { // A source without an MCP row leaves the copy without one: the // default-config semantics and boot EnsureDefaultMcpConfig cover @@ -525,6 +787,9 @@ func (c *Controller) DeleteProfile(ctx context.Context, id string, force bool) ( } return nil, err } + if profileKind(profile) == dynamicProfileKind && !c.dynamicAgentRoutingEnabled { + return nil, ErrDynamicAgentRoutingDisabled + } if err := c.prepareProfileDeletion(ctx, id, force); err != nil { return nil, err } @@ -569,6 +834,13 @@ func (c *Controller) DeleteProfile(ctx context.Context, id string, force bool) ( // Neither branch disables anything: DeleteProfile disables referencing // automations before the row goes away and referencing watchers after it does. func (c *Controller) prepareProfileDeletion(ctx context.Context, profileID string, force bool) error { + dynamicRefs, err := c.listDynamicProfileReferences(ctx, profileID) + if err != nil { + return err + } + if !force && len(dynamicRefs) > 0 { + return &ErrProfileInUseDetail{DynamicProfiles: dynamicRefs} + } routingTierRefs, err := c.listRoutingTierReferences(ctx, profileID) if err != nil { return err @@ -586,7 +858,7 @@ func (c *Controller) prepareProfileDeletion(ctx context.Context, profileID strin } if c.sessionChecker == nil { if !force && len(utilityRefs) > 0 { - return &ErrProfileInUseDetail{UtilityAgents: utilityRefs} + return &ErrProfileInUseDetail{UtilityAgents: utilityRefs, DynamicProfiles: dynamicRefs} } return nil } @@ -624,10 +896,11 @@ func (c *Controller) prepareProfileDeletion(ctx context.Context, profileID strin // and a schedule fails quietly, hours later, with nobody watching. if len(activeTasks) > 0 || len(watcherRefs) > 0 || len(automationRefs) > 0 || len(utilityRefs) > 0 { return &ErrProfileInUseDetail{ - ActiveSessions: activeTasks, - Watchers: watcherRefs, - Automations: automationRefs, - UtilityAgents: utilityRefs, + ActiveSessions: activeTasks, + Watchers: watcherRefs, + Automations: automationRefs, + UtilityAgents: utilityRefs, + DynamicProfiles: dynamicRefs, } } } @@ -648,6 +921,26 @@ func (c *Controller) listRoutingTierReferences(ctx context.Context, profileID st return refs, nil } +func (c *Controller) listDynamicProfileReferences(ctx context.Context, profileID string) ([]DynamicProfileReference, error) { + dynamicRepo, ok := c.repo.(store.DynamicProfileRepository) + if !ok { + return nil, nil + } + refs, err := dynamicRepo.ListDynamicProfileReferencesByExecutionProfile(ctx, profileID) + if err != nil { + return nil, fmt.Errorf("check dynamic profiles using this profile: %w", err) + } + out := make([]DynamicProfileReference, 0, len(refs)) + for _, ref := range refs { + out = append(out, DynamicProfileReference{ + ID: ref.ProfileID, + Name: ref.Name, + Deleted: ref.DeletedAt != nil, + }) + } + return out, nil +} + // disableReferencingAutomations turns off every automation bound to the profile // about to be deleted, and reports failure so DeleteProfile can abort. // @@ -788,10 +1081,47 @@ func toAgentDTO(agent *models.Agent, profiles []*models.AgentProfile) dto.AgentD return result } +// decorateAgentDTO attaches the dynamic document only after the parent +// profile list has been assembled. Concrete profiles stay on the existing +// lightweight path, while settings clients get the version and ordered +// candidates for dynamic profiles without reading secrets. +func (c *Controller) decorateAgentDTO(ctx context.Context, result *dto.AgentDTO) error { + if result == nil { + return nil + } + dynamicRepo, ok := c.repo.(store.DynamicProfileRepository) + if !ok { + for _, profile := range result.Profiles { + if profile.Kind == dynamicProfileKind { + return fmt.Errorf("dynamic profile store is unavailable") + } + } + return nil + } + for index := range result.Profiles { + if result.Profiles[index].Kind != dynamicProfileKind { + continue + } + config, routes, err := dynamicRepo.GetDynamicAgentProfile(ctx, result.Profiles[index].ID) + if errors.Is(err, sql.ErrNoRows) { + continue + } + if err != nil { + return err + } + result.Profiles[index].Dynamic, err = dynamicProfileDTO(config, routes) + if err != nil { + return err + } + } + return nil +} + func toProfileDTO(profile *models.AgentProfile) dto.AgentProfileDTO { return dto.AgentProfileDTO{ ID: profile.ID, AgentID: profile.AgentID, + Kind: profileKind(profile), Name: profile.Name, AgentDisplayName: profile.AgentDisplayName, Model: profile.Model, @@ -813,6 +1143,13 @@ func toProfileDTO(profile *models.AgentProfile) dto.AgentProfileDTO { } } +func profileKind(profile *models.AgentProfile) string { + if profile != nil && profile.AgentID == agents.DynamicAgentID { + return dynamicProfileKind + } + return "concrete" +} + func cliFlagsToDTO(in []models.CLIFlag) []dto.CLIFlagDTO { out := make([]dto.CLIFlagDTO, len(in)) for i, f := range in { diff --git a/apps/backend/internal/agent/settings/controller/profile_duplicate_test.go b/apps/backend/internal/agent/settings/controller/profile_duplicate_test.go index 96e9cb927c..7bf8c3378e 100644 --- a/apps/backend/internal/agent/settings/controller/profile_duplicate_test.go +++ b/apps/backend/internal/agent/settings/controller/profile_duplicate_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/settings/models" "github.com/kandev/kandev/internal/agent/settings/store" ) @@ -293,6 +294,20 @@ func TestDuplicateProfile_RejectsOfficeScopedSource(t *testing.T) { } } +func TestDuplicateProfile_RejectsDynamicSource(t *testing.T) { + source := sourceProfile() + source.AgentID = agents.DynamicAgentID + ctrl, st := duplicateSetup(source) + + _, err := ctrl.DuplicateProfile(context.Background(), DuplicateProfileRequest{ID: source.ID}) + if !errors.Is(err, ErrDynamicProfileDuplicationUnsupported) { + t.Fatalf("err = %v, want dynamic duplication unsupported", err) + } + if len(st.created) != 0 { + t.Errorf("dynamic source created %d copies, want 0", len(st.created)) + } +} + // TestDuplicateProfile_NameSuffixFromEmptySourceName pins the copy name when the source name is empty. func TestDuplicateProfile_NameSuffixFromEmptySourceName(t *testing.T) { source := sourceProfile() diff --git a/apps/backend/internal/agent/settings/controller/reconciler.go b/apps/backend/internal/agent/settings/controller/reconciler.go index 534ccb45ec..3482acf64a 100644 --- a/apps/backend/internal/agent/settings/controller/reconciler.go +++ b/apps/backend/internal/agent/settings/controller/reconciler.go @@ -91,6 +91,11 @@ func (r *ProfileReconciler) Run(ctx context.Context) error { return fmt.Errorf("reconciler not fully configured") } + // Virtual families are settings identities rather than inference agents. + // Seed their parent rows before orphan cleanup so the permanent dynamic + // family is protected even when no concrete provider probe is ready. + r.ensureVirtualFamilies(ctx) + // Orphan cleanup first: removed agents can't come back, regardless of // probe state. The cleanup itself fails closed when the registry is empty // so a transient registry/bootstrap issue cannot mass-delete profiles. @@ -107,6 +112,32 @@ func (r *ProfileReconciler) Run(ctx context.Context) error { return nil } +func (r *ProfileReconciler) ensureVirtualFamilies(ctx context.Context) { + for _, ag := range r.registry.List() { + if !agents.IsVirtualAgent(ag) { + continue + } + if _, err := r.store.GetAgentByName(ctx, ag.ID()); err == nil { + continue + } else if !errors.Is(err, sql.ErrNoRows) { + r.log.Warn("reconcile: look up virtual agent failed", + zap.String("agent_id", ag.ID()), zap.Error(err)) + continue + } + parent := &models.Agent{ + ID: ag.ID(), + Name: ag.ID(), + SupportsMCP: false, + } + if err := r.store.CreateAgent(ctx, parent); err != nil { + r.log.Warn("reconcile: seed virtual agent failed", + zap.String("agent_id", ag.ID()), zap.Error(err)) + continue + } + r.log.Info("seeded virtual agent family", zap.String("agent_id", ag.ID())) + } +} + // cleanupOrphans soft-deletes profiles whose DB agent row references an // agent type that is no longer registered or enabled (e.g. profiles left // behind by the removed streamjson-based variants). The settings store keys diff --git a/apps/backend/internal/agent/settings/controller/reconciler_test.go b/apps/backend/internal/agent/settings/controller/reconciler_test.go index 1737e7e4bb..0677f830fa 100644 --- a/apps/backend/internal/agent/settings/controller/reconciler_test.go +++ b/apps/backend/internal/agent/settings/controller/reconciler_test.go @@ -143,8 +143,10 @@ func TestFakeStoreAgentProfileReadRequiresExplicitUpdate(t *testing.T) { } func (f *fakeStore) CreateAgent(_ context.Context, a *models.Agent) error { - f.nextAgentID++ - a.ID = "agent-" + strconv.Itoa(f.nextAgentID) + if a.ID == "" { + f.nextAgentID++ + a.ID = "agent-" + strconv.Itoa(f.nextAgentID) + } stored := copyAgent(a) f.agents[a.ID] = stored f.byName[a.Name] = stored @@ -462,6 +464,35 @@ func TestProfileReconciler_SeedsDefaultProfile(t *testing.T) { } } +func TestProfileReconciler_SeedsDynamicVirtualFamily(t *testing.T) { + st := newFakeStore() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("logger: %v", err) + } + reg := registry.NewRegistry(log) + reg.LoadDefaults() + r := NewProfileReconciler( + &fakeCapReader{caps: map[string]hostutility.AgentCapabilities{}}, + reg, + st, + log, + ) + if err := r.Run(context.Background()); err != nil { + t.Fatalf("Run: %v", err) + } + parent, err := st.GetAgentByName(context.Background(), agents.DynamicAgentID) + if err != nil { + t.Fatalf("dynamic family lookup: %v", err) + } + if parent.ID != agents.DynamicAgentID { + t.Fatalf("dynamic family ID = %q, want stable ID %q", parent.ID, agents.DynamicAgentID) + } + if len(st.created) != 0 { + t.Fatalf("dynamic family seeded a concrete profile: %+v", st.created) + } +} + // TestProfileReconciler_DoesNotReseedAfterUserDelete pins the core fix: an // agent whose only profile the user deleted (zero live rows, but soft-deleted // rows present) must NOT have a default profile recreated on the next boot. @@ -1047,8 +1078,8 @@ func TestProfileReconciler_LogsOrphanCleanupSummary(t *testing.T) { t.Fatalf("expected 1 summary log, got %d", len(entries)) } fields := entries[0].ContextMap() - if fields["db_agent_count"] != int64(1) { - t.Errorf("db_agent_count = %v, want 1", fields["db_agent_count"]) + if fields["db_agent_count"] != int64(2) { + t.Errorf("db_agent_count = %v, want 2 including the dynamic virtual family", fields["db_agent_count"]) } if fields["orphan_agent_count"] != int64(1) { t.Errorf("orphan_agent_count = %v, want 1", fields["orphan_agent_count"]) diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index 5f3f3282f4..f923a83a20 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -9,6 +9,7 @@ import ( type AgentProfileDTO struct { ID string `json:"id"` AgentID string `json:"agent_id"` + Kind string `json:"kind"` Name string `json:"name"` AgentDisplayName string `json:"agent_display_name"` Model string `json:"model"` @@ -41,9 +42,56 @@ type AgentProfileDTO struct { WorkspaceID string `json:"workspace_id,omitempty"` // BillingType is computed at read time from credential files — not stored in the DB. // Values: "api_key" | "subscription". Empty for agents that don't support billing type detection. - BillingType string `json:"billing_type,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + BillingType string `json:"billing_type,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Dynamic *DynamicAgentProfileDTO `json:"dynamic,omitempty"` +} + +// DynamicAgentProfileDTO is the versioned routing document attached to a +// profile whose kind is "dynamic". Candidates stay opaque profile IDs on the +// wire; the server resolves and validates their safe display data. +type DynamicAgentProfileDTO struct { + Version int64 `json:"version"` + Candidates []DynamicAgentCandidateDTO `json:"candidates"` +} + +// DynamicAgentPolicyDTO is the canonical, versioned policy document persisted +// for one dynamic candidate. The two classes are deliberately explicit so a +// missing class can never silently inherit another class's behavior. +type DynamicAgentPolicyDTO struct { + Version int64 `json:"version"` + Transient DynamicErrorPolicyDTO `json:"transient"` + Hard DynamicErrorPolicyDTO `json:"hard"` +} + +type DynamicErrorPolicyDTO struct { + Retry DynamicRetryPolicyDTO `json:"retry"` + WaitForReset DynamicResetWaitPolicyDTO `json:"wait_for_reset"` + OnExhausted string `json:"on_exhausted"` +} + +type DynamicRetryPolicyDTO struct { + Enabled bool `json:"enabled"` + MaxRetries int64 `json:"max_retries"` + InitialIntervalSeconds int64 `json:"initial_interval_seconds"` +} + +type DynamicResetWaitPolicyDTO struct { + Enabled bool `json:"enabled"` + MaxWaitSeconds int64 `json:"max_wait_seconds"` +} + +// DynamicAgentCandidateDTO is one ordered concrete execution profile and its +// provider-error policy. Rules remains a read-compatible legacy input shape; +// profile CRUD normalizes it into Policies before persistence and never emits +// it in canonical responses. +type DynamicAgentCandidateDTO struct { + Position int `json:"position"` + ExecutionProfileID string `json:"execution_profile_id"` + Enabled bool `json:"enabled"` + Policies *DynamicAgentPolicyDTO `json:"policies,omitempty"` + Rules map[string]string `json:"rules,omitempty"` } // GetWorkspaceID lets struct-shaped profile payloads (e.g. the MCP event-bus diff --git a/apps/backend/internal/agent/settings/handlers/agent_crud_handlers_test.go b/apps/backend/internal/agent/settings/handlers/agent_crud_handlers_test.go index 3dbe547a66..9dfae8e1ba 100644 --- a/apps/backend/internal/agent/settings/handlers/agent_crud_handlers_test.go +++ b/apps/backend/internal/agent/settings/handlers/agent_crud_handlers_test.go @@ -74,6 +74,7 @@ func wantProfileDTO(id, agentID, name, billingType string) dto.AgentProfileDTO { return dto.AgentProfileDTO{ ID: id, AgentID: agentID, + Kind: "concrete", Name: name, AgentDisplayName: "Display", Model: "model-a", diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index bb6e17385f..bd0e1d2929 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -525,18 +525,19 @@ func (h *Handlers) httpUpdateProfileMcpConfig(c *gin.Context) { } type createProfileRequest struct { - Name string `json:"name"` - Model string `json:"model"` - FallbackModel string `json:"fallback_model,omitempty"` - AutoFallback bool `json:"auto_fallback"` - Mode string `json:"mode,omitempty"` - ConfigOptions map[string]string `json:"config_options,omitempty"` - AllowIndexing bool `json:"allow_indexing"` - AutoApprove bool `json:"auto_approve"` - CLIPassthrough bool `json:"cli_passthrough"` - CLIFlags []dto.CLIFlagDTO `json:"cli_flags,omitempty"` - EnvVars []dto.ProfileEnvVarDTO `json:"env_vars,omitempty"` - CommandPrefix string `json:"command_prefix,omitempty"` + Name string `json:"name"` + Model string `json:"model"` + FallbackModel string `json:"fallback_model,omitempty"` + AutoFallback bool `json:"auto_fallback"` + Mode string `json:"mode,omitempty"` + ConfigOptions map[string]string `json:"config_options,omitempty"` + AllowIndexing bool `json:"allow_indexing"` + AutoApprove bool `json:"auto_approve"` + CLIPassthrough bool `json:"cli_passthrough"` + CLIFlags []dto.CLIFlagDTO `json:"cli_flags,omitempty"` + EnvVars []dto.ProfileEnvVarDTO `json:"env_vars,omitempty"` + CommandPrefix string `json:"command_prefix,omitempty"` + Dynamic *dto.DynamicAgentProfileDTO `json:"dynamic,omitempty"` } func (h *Handlers) httpCreateProfile(c *gin.Context) { @@ -563,8 +564,20 @@ func (h *Handlers) httpCreateProfile(c *gin.Context) { CLIFlags: body.CLIFlags, EnvVars: body.EnvVars, CommandPrefix: body.CommandPrefix, + Dynamic: body.Dynamic, }) if err != nil { + if errors.Is(err, controller.ErrDynamicAgentRoutingDisabled) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, controller.ErrDynamicProfileCandidatesRequired) || + errors.Is(err, controller.ErrDynamicProfilePositions) || + errors.Is(err, controller.ErrDynamicProfileRule) || + errors.Is(err, controller.ErrDynamicProfileCandidate) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } if errors.Is(err, controller.ErrInvalidProfileEnvVars) || errors.Is(err, controller.ErrInvalidCommandPrefix) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -578,19 +591,20 @@ func (h *Handlers) httpCreateProfile(c *gin.Context) { } type updateProfileRequest struct { - Name *string `json:"name,omitempty"` - Model *string `json:"model,omitempty"` - FallbackModel *string `json:"fallback_model,omitempty"` - AutoFallback *bool `json:"auto_fallback,omitempty"` - Mode *string `json:"mode,omitempty"` - ConfigOptions *map[string]string `json:"config_options,omitempty"` - AllowIndexing *bool `json:"allow_indexing,omitempty"` - AutoApprove *bool `json:"auto_approve,omitempty"` - CLIPassthrough *bool `json:"cli_passthrough,omitempty"` - Enabled *bool `json:"enabled,omitempty"` - CLIFlags *[]dto.CLIFlagDTO `json:"cli_flags,omitempty"` - EnvVars *[]dto.ProfileEnvVarDTO `json:"env_vars,omitempty"` - CommandPrefix *string `json:"command_prefix,omitempty"` + Name *string `json:"name,omitempty"` + Model *string `json:"model,omitempty"` + FallbackModel *string `json:"fallback_model,omitempty"` + AutoFallback *bool `json:"auto_fallback,omitempty"` + Mode *string `json:"mode,omitempty"` + ConfigOptions *map[string]string `json:"config_options,omitempty"` + AllowIndexing *bool `json:"allow_indexing,omitempty"` + AutoApprove *bool `json:"auto_approve,omitempty"` + CLIPassthrough *bool `json:"cli_passthrough,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + CLIFlags *[]dto.CLIFlagDTO `json:"cli_flags,omitempty"` + EnvVars *[]dto.ProfileEnvVarDTO `json:"env_vars,omitempty"` + CommandPrefix *string `json:"command_prefix,omitempty"` + Dynamic *dto.DynamicAgentProfileDTO `json:"dynamic,omitempty"` } func (h *Handlers) httpUpdateProfile(c *gin.Context) { @@ -618,6 +632,7 @@ func (h *Handlers) httpUpdateProfile(c *gin.Context) { CLIFlags: body.CLIFlags, EnvVars: body.EnvVars, CommandPrefix: body.CommandPrefix, + Dynamic: body.Dynamic, Force: c.Query("force") == queryTrue, }) if err != nil { @@ -629,9 +644,25 @@ func (h *Handlers) httpUpdateProfile(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if errors.Is(err, controller.ErrDynamicAgentRoutingDisabled) || + errors.Is(err, controller.ErrDynamicProfileVersionConflict) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, controller.ErrDynamicProfileCandidatesRequired) || + errors.Is(err, controller.ErrDynamicProfilePositions) || + errors.Is(err, controller.ErrDynamicProfileRule) || + errors.Is(err, controller.ErrDynamicProfileCandidate) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } var inUseErr *controller.ErrProfileInUseDetail if errors.As(err, &inUseErr) { - c.JSON(http.StatusConflict, gin.H{"error": "agent profile is in use", "utility_agents": inUseErr.UtilityAgents}) + c.JSON(http.StatusConflict, gin.H{ + "error": "agent profile is in use", + "utility_agents": inUseErr.UtilityAgents, + "dynamic_profiles": inUseErr.DynamicProfiles, + }) return } h.logger.Error("failed to update profile", zap.Error(err)) @@ -660,6 +691,10 @@ func (h *Handlers) httpDuplicateProfile(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "agent profile not found"}) return } + if errors.Is(err, controller.ErrDynamicProfileDuplicationUnsupported) { + c.JSON(http.StatusConflict, gin.H{"error": "dynamic profiles cannot be duplicated"}) + return + } h.logger.Error("failed to duplicate profile", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to duplicate profile"}) return @@ -705,12 +740,13 @@ func (h *Handlers) httpDeleteProfile(c *gin.Context) { var inUseErr *controller.ErrProfileInUseDetail if errors.As(err, &inUseErr) { c.JSON(http.StatusConflict, gin.H{ - "error": "agent profile is in use", - "active_sessions": inUseErr.ActiveSessions, - "watchers": inUseErr.Watchers, - "routing_tiers": inUseErr.RoutingTiers, - "automations": inUseErr.Automations, - "utility_agents": inUseErr.UtilityAgents, + "error": "agent profile is in use", + "active_sessions": inUseErr.ActiveSessions, + "watchers": inUseErr.Watchers, + "routing_tiers": inUseErr.RoutingTiers, + "automations": inUseErr.Automations, + "utility_agents": inUseErr.UtilityAgents, + "dynamic_profiles": inUseErr.DynamicProfiles, }) return } diff --git a/apps/backend/internal/agent/settings/models/dynamic.go b/apps/backend/internal/agent/settings/models/dynamic.go new file mode 100644 index 0000000000..fbcac78529 --- /dev/null +++ b/apps/backend/internal/agent/settings/models/dynamic.go @@ -0,0 +1,32 @@ +package models + +import "time" + +// DynamicAgentProfile stores the optimistic version for one dynamic profile's +// routing document. The parent agent_profiles row remains the profile's +// identity and display configuration. +type DynamicAgentProfile struct { + ProfileID string `json:"profile_id"` + Version int64 `json:"version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// DynamicAgentRoute is one ordered concrete candidate in a dynamic profile. +// RulesJSON is normalized by the dynamic-profile controller before persistence. +type DynamicAgentRoute struct { + DynamicProfileID string `json:"dynamic_profile_id"` + Position int `json:"position"` + ExecutionProfileID string `json:"execution_profile_id"` + Enabled bool `json:"enabled"` + RulesJSON string `json:"rules_json"` +} + +// DynamicProfileReference describes a dynamic profile that still points at a +// concrete profile. Soft-deleted parents are included so delete confirmation +// can explain stale configuration instead of silently losing the dependency. +type DynamicProfileReference struct { + ProfileID string `json:"profile_id"` + Name string `json:"name"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` +} diff --git a/apps/backend/internal/agent/settings/store/errors.go b/apps/backend/internal/agent/settings/store/errors.go index 85c31fdaf8..926b5f4a59 100644 --- a/apps/backend/internal/agent/settings/store/errors.go +++ b/apps/backend/internal/agent/settings/store/errors.go @@ -7,3 +7,7 @@ import "errors" // "the profile never existed" (caller passed a bad ID). Wrapped — caller uses // errors.Is. var ErrAgentProfileDeleted = errors.New("agent profile soft-deleted") + +// ErrDynamicProfileVersionConflict means a dynamic profile was edited after +// the caller read its version. The caller must reload before retrying. +var ErrDynamicProfileVersionConflict = errors.New("dynamic profile version conflict") diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go index 28db73b66e..14795719ac 100644 --- a/apps/backend/internal/agent/settings/store/sqlite.go +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -119,9 +119,30 @@ func (r *sqliteRepository) initSchema() error { FOREIGN KEY (profile_id) REFERENCES agent_profiles(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS dynamic_agent_profiles ( + profile_id TEXT PRIMARY KEY, + version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (profile_id) REFERENCES agent_profiles(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS dynamic_agent_routes ( + dynamic_profile_id TEXT NOT NULL, + position INTEGER NOT NULL, + execution_profile_id TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + rules_json TEXT NOT NULL DEFAULT '{}', + PRIMARY KEY (dynamic_profile_id, position), + FOREIGN KEY (dynamic_profile_id) REFERENCES dynamic_agent_profiles(profile_id) ON DELETE CASCADE, + FOREIGN KEY (execution_profile_id) REFERENCES agent_profiles(id) ON DELETE RESTRICT + ); + DROP INDEX IF EXISTS idx_agents_name; CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_name ON agents(name); CREATE INDEX IF NOT EXISTS idx_agent_profiles_agent_id ON agent_profiles(agent_id); + CREATE INDEX IF NOT EXISTS idx_dynamic_agent_routes_execution_profile + ON dynamic_agent_routes(execution_profile_id); ` // Note: indexes on new office-enrichment columns (workspace_id, role, // reports_to) are created later in migrateOfficeEnrichmentColumns — @@ -524,6 +545,213 @@ func (r *sqliteRepository) CreateAgentProfile(ctx context.Context, profile *mode return r.insertAgentProfile(ctx, r.db, profile) } +var _ DynamicProfileRepository = (*sqliteRepository)(nil) + +// CreateDynamicAgentProfile writes the dynamic document and its ordered route +// rows in one transaction. The parent agent_profiles row is created by the +// controller first; this transaction is the optimistic-routing document's +// atomic unit. +func (r *sqliteRepository) CreateDynamicAgentProfile( + ctx context.Context, + profile *models.DynamicAgentProfile, + routes []models.DynamicAgentRoute, +) error { + if profile == nil || profile.ProfileID == "" { + return fmt.Errorf("dynamic profile ID is required") + } + if profile.Version <= 0 { + profile.Version = 1 + } + now := time.Now().UTC() + if profile.CreatedAt.IsZero() { + profile.CreatedAt = now + } + profile.UpdatedAt = now + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, tx.Rebind(` + INSERT INTO dynamic_agent_profiles (profile_id, version, created_at, updated_at) + VALUES (?, ?, ?, ?) + `), profile.ProfileID, profile.Version, profile.CreatedAt, profile.UpdatedAt); err != nil { + return err + } + if err := insertDynamicRoutes(ctx, tx, profile.ProfileID, routes); err != nil { + return err + } + return tx.Commit() +} + +func insertDynamicRoutes(ctx context.Context, execer profileExecer, profileID string, routes []models.DynamicAgentRoute) error { + for _, route := range routes { + if _, err := execer.ExecContext(ctx, execer.Rebind(` + INSERT INTO dynamic_agent_routes + (dynamic_profile_id, position, execution_profile_id, enabled, rules_json) + VALUES (?, ?, ?, ?, ?) + `), profileID, route.Position, route.ExecutionProfileID, + dialect.BoolToInt(route.Enabled), route.RulesJSON); err != nil { + return err + } + } + return nil +} + +func (r *sqliteRepository) GetDynamicAgentProfile( + ctx context.Context, + profileID string, +) (*models.DynamicAgentProfile, []models.DynamicAgentRoute, error) { + profile := &models.DynamicAgentProfile{} + if err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` + SELECT profile_id, version, created_at, updated_at + FROM dynamic_agent_profiles WHERE profile_id = ? + `), profileID).Scan(&profile.ProfileID, &profile.Version, &profile.CreatedAt, &profile.UpdatedAt); err != nil { + return nil, nil, err + } + rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(` + SELECT dynamic_profile_id, position, execution_profile_id, enabled, rules_json + FROM dynamic_agent_routes WHERE dynamic_profile_id = ? ORDER BY position ASC + `), profileID) + if err != nil { + return nil, nil, err + } + defer func() { _ = rows.Close() }() + routes := make([]models.DynamicAgentRoute, 0) + for rows.Next() { + var route models.DynamicAgentRoute + var enabled int + if err := rows.Scan(&route.DynamicProfileID, &route.Position, &route.ExecutionProfileID, &enabled, &route.RulesJSON); err != nil { + return nil, nil, err + } + route.Enabled = enabled != 0 + routes = append(routes, route) + } + if err := rows.Err(); err != nil { + return nil, nil, err + } + return profile, routes, nil +} + +// UpdateDynamicAgentProfile replaces the complete route document only when +// expectedVersion still owns the row. The new version and route replacement +// commit together, so readers never observe a partially edited candidate list. +func (r *sqliteRepository) UpdateDynamicAgentProfile( + ctx context.Context, + profile *models.DynamicAgentProfile, + expectedVersion int64, + routes []models.DynamicAgentRoute, +) error { + if profile == nil || profile.ProfileID == "" { + return fmt.Errorf("dynamic profile ID is required") + } + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if err := r.updateDynamicAgentProfileTx(ctx, tx, profile, expectedVersion, routes); err != nil { + return err + } + return tx.Commit() +} + +func (r *sqliteRepository) updateDynamicAgentProfileTx( + ctx context.Context, + execer profileExecer, + profile *models.DynamicAgentProfile, + expectedVersion int64, + routes []models.DynamicAgentRoute, +) error { + now := time.Now().UTC() + result, err := execer.ExecContext(ctx, execer.Rebind(` + UPDATE dynamic_agent_profiles + SET version = version + 1, updated_at = ? + WHERE profile_id = ? AND version = ? + `), now, profile.ProfileID, expectedVersion) + if err != nil { + return err + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + return ErrDynamicProfileVersionConflict + } + if _, err := execer.ExecContext(ctx, execer.Rebind( + `DELETE FROM dynamic_agent_routes WHERE dynamic_profile_id = ?`), profile.ProfileID); err != nil { + return err + } + if err := insertDynamicRoutes(ctx, execer, profile.ProfileID, routes); err != nil { + return err + } + profile.Version = expectedVersion + 1 + profile.UpdatedAt = now + return nil +} + +// UpdateAgentProfileWithDynamic commits the ordinary profile fields and the +// versioned dynamic route document together. A version conflict rolls back +// both writes, so a stale editor cannot persist a partial base-profile change. +func (r *sqliteRepository) UpdateAgentProfileWithDynamic( + ctx context.Context, + profile *models.AgentProfile, + dynamic *models.DynamicAgentProfile, + expectedVersion int64, + routes []models.DynamicAgentRoute, +) error { + if profile == nil || dynamic == nil || profile.ID == "" || dynamic.ProfileID != profile.ID { + return fmt.Errorf("profile and dynamic profile IDs are required") + } + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if err := r.updateAgentProfile(ctx, tx, profile); err != nil { + return err + } + if err := r.updateDynamicAgentProfileTx(ctx, tx, dynamic, expectedVersion, routes); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + return nil +} + +func (r *sqliteRepository) ListDynamicProfileReferencesByExecutionProfile( + ctx context.Context, + profileID string, +) ([]models.DynamicProfileReference, error) { + rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(` + SELECT p.id, p.name, p.deleted_at + FROM dynamic_agent_routes r + JOIN agent_profiles p ON p.id = r.dynamic_profile_id + WHERE r.execution_profile_id = ? + ORDER BY p.name, p.id + `), profileID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + refs := make([]models.DynamicProfileReference, 0) + seen := make(map[string]struct{}) + for rows.Next() { + var ref models.DynamicProfileReference + if err := rows.Scan(&ref.ProfileID, &ref.Name, &ref.DeletedAt); err != nil { + return nil, err + } + if _, ok := seen[ref.ProfileID]; ok { + continue + } + seen[ref.ProfileID] = struct{}{} + refs = append(refs, ref) + } + return refs, rows.Err() +} + // DuplicateAgentProfile creates an independent copy of a profile in a single // transaction: the row is inserted with the caller-provided Enabled state (a // duplicate of a disabled profile must not become briefly selectable) and the @@ -884,6 +1112,10 @@ func envVarsToJSON(envVars []models.ProfileEnvVar) (string, error) { } func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *models.AgentProfile) error { + return r.updateAgentProfile(ctx, r.db, profile) +} + +func (r *sqliteRepository) updateAgentProfile(ctx context.Context, execer profileExecer, profile *models.AgentProfile) error { profile.UpdatedAt = time.Now().UTC() cliFlagsJSON, err := cliFlagsToJSON(profile.CLIFlags) if err != nil { @@ -897,7 +1129,7 @@ func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *mode if err != nil { return err } - result, err := r.db.ExecContext(ctx, r.db.Rebind(` + result, err := execer.ExecContext(ctx, execer.Rebind(` UPDATE agent_profiles SET agent_id = ?, name = ?, agent_display_name = ?, model = ?, mode = ?, migrated_from = ?, auto_approve = ?, dangerously_skip_permissions = ?, allow_indexing = ?, diff --git a/apps/backend/internal/agent/settings/store/sqlite_dynamic_profile_test.go b/apps/backend/internal/agent/settings/store/sqlite_dynamic_profile_test.go new file mode 100644 index 0000000000..da7b7a543c --- /dev/null +++ b/apps/backend/internal/agent/settings/store/sqlite_dynamic_profile_test.go @@ -0,0 +1,137 @@ +package store + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/agent/settings/models" +) + +func TestSQLiteRepositoryCreatesDynamicProfileSchema(t *testing.T) { + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + repo, err := newSQLiteRepository(db, db, nil, false) + if err != nil { + t.Fatalf("newSQLiteRepository: %v", err) + } + ctx := context.Background() + parent := &models.Agent{ID: "dynamic", Name: "dynamic"} + if err := repo.CreateAgent(ctx, parent); err != nil { + t.Fatalf("create dynamic family: %v", err) + } + profile := &models.AgentProfile{ + AgentID: parent.ID, + Name: "Balanced", + AgentDisplayName: "Dynamic", + } + if err := repo.CreateAgentProfile(ctx, profile); err != nil { + t.Fatalf("create dynamic profile: %v", err) + } + if _, err := db.ExecContext(ctx, ` + INSERT INTO dynamic_agent_profiles (profile_id, version, created_at, updated_at) + VALUES (?, ?, ?, ?) + `, profile.ID, 3, time.Now().UTC(), time.Now().UTC()); err != nil { + t.Fatalf("insert dynamic profile config: %v", err) + } + if _, err := db.ExecContext(ctx, ` + INSERT INTO dynamic_agent_routes + (dynamic_profile_id, position, execution_profile_id, enabled, rules_json) + VALUES (?, ?, ?, ?, ?) + `, profile.ID, 0, "concrete-profile", 1, `{"on_provider_error":"try_next"}`); err != nil { + t.Fatalf("insert dynamic route: %v", err) + } + + var version int + if err := db.GetContext(ctx, &version, + `SELECT version FROM dynamic_agent_profiles WHERE profile_id = ?`, profile.ID); err != nil { + t.Fatalf("read dynamic profile version: %v", err) + } + if version != 3 { + t.Fatalf("version = %d, want 3", version) + } + var routes int + if err := db.GetContext(ctx, &routes, + `SELECT COUNT(*) FROM dynamic_agent_routes WHERE dynamic_profile_id = ?`, profile.ID); err != nil { + t.Fatalf("count dynamic routes: %v", err) + } + if routes != 1 { + t.Fatalf("dynamic routes = %d, want 1", routes) + } +} + +func TestSQLiteRepositoryDynamicProfileCRUDUsesOptimisticVersions(t *testing.T) { + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + repo, err := newSQLiteRepository(db, db, nil, false) + if err != nil { + t.Fatalf("newSQLiteRepository: %v", err) + } + ctx := context.Background() + if err := repo.CreateAgent(ctx, &models.Agent{ID: "dynamic", Name: "dynamic"}); err != nil { + t.Fatalf("create dynamic family: %v", err) + } + for _, id := range []string{"profile-dynamic", "candidate-a", "candidate-b"} { + if err := repo.CreateAgentProfile(ctx, &models.AgentProfile{ + ID: id, + AgentID: "dynamic", + Name: id, + AgentDisplayName: "Dynamic", + }); err != nil { + t.Fatalf("create profile %s: %v", id, err) + } + } + // Move the concrete candidates to a real inference-family parent. The + // store must preserve the foreign-key relationship without interpreting it. + if _, err := db.ExecContext(ctx, ` + INSERT INTO agents (id, name, created_at, updated_at) VALUES (?, ?, ?, ?) + `, "concrete-agent", "concrete-agent", time.Now().UTC(), time.Now().UTC()); err != nil { + t.Fatalf("create concrete family: %v", err) + } + if _, err := db.ExecContext(ctx, ` + UPDATE agent_profiles SET agent_id = ? WHERE id IN (?, ?) + `, "concrete-agent", "candidate-a", "candidate-b"); err != nil { + t.Fatalf("move candidates: %v", err) + } + + created := &models.DynamicAgentProfile{ProfileID: "profile-dynamic", Version: 1} + routes := []models.DynamicAgentRoute{ + {DynamicProfileID: created.ProfileID, Position: 0, ExecutionProfileID: "candidate-a", Enabled: true, RulesJSON: `{"on_provider_error":"try_next"}`}, + {DynamicProfileID: created.ProfileID, Position: 1, ExecutionProfileID: "candidate-b", Enabled: false, RulesJSON: `{}`}, + } + if err := repo.CreateDynamicAgentProfile(ctx, created, routes); err != nil { + t.Fatalf("create dynamic config: %v", err) + } + got, gotRoutes, err := repo.GetDynamicAgentProfile(ctx, created.ProfileID) + if err != nil { + t.Fatalf("get dynamic config: %v", err) + } + if got.Version != 1 || len(gotRoutes) != 2 || gotRoutes[0].ExecutionProfileID != "candidate-a" { + t.Fatalf("dynamic config = %#v, routes = %#v", got, gotRoutes) + } + + updated := &models.DynamicAgentProfile{ProfileID: created.ProfileID} + if err := repo.UpdateDynamicAgentProfile(ctx, updated, 1, []models.DynamicAgentRoute{ + {DynamicProfileID: created.ProfileID, Position: 0, ExecutionProfileID: "candidate-b", Enabled: true, RulesJSON: `{}`}, + }); err != nil { + t.Fatalf("update dynamic config: %v", err) + } + if updated.Version != 2 { + t.Fatalf("updated version = %d, want 2", updated.Version) + } + if err := repo.UpdateDynamicAgentProfile(ctx, &models.DynamicAgentProfile{ProfileID: created.ProfileID}, 1, nil); !errors.Is(err, ErrDynamicProfileVersionConflict) { + t.Fatalf("stale update error = %v, want %v", err, ErrDynamicProfileVersionConflict) + } +} diff --git a/apps/backend/internal/agent/settings/store/store.go b/apps/backend/internal/agent/settings/store/store.go index 073f7daa4c..90efe4cda0 100644 --- a/apps/backend/internal/agent/settings/store/store.go +++ b/apps/backend/internal/agent/settings/store/store.go @@ -79,3 +79,28 @@ type Repository interface { Close() error } + +// DynamicProfileRepository is the optional extension implemented by settings +// stores that persist the dynamic profile document. Keeping it separate from +// Repository lets small controller fakes and plugin adapters retain the +// existing profile contract while dynamic routing is rolled out. +type DynamicProfileRepository interface { + CreateDynamicAgentProfile(ctx context.Context, profile *models.DynamicAgentProfile, routes []models.DynamicAgentRoute) error + GetDynamicAgentProfile(ctx context.Context, profileID string) (*models.DynamicAgentProfile, []models.DynamicAgentRoute, error) + UpdateDynamicAgentProfile(ctx context.Context, profile *models.DynamicAgentProfile, expectedVersion int64, routes []models.DynamicAgentRoute) error + ListDynamicProfileReferencesByExecutionProfile(ctx context.Context, profileID string) ([]models.DynamicProfileReference, error) +} + +// AtomicDynamicProfileRepository updates the base profile row and its +// optimistic-routing document in one transaction. Controllers use this +// optional extension when a request changes both surfaces so a stale route +// version cannot leave the base profile partially updated. +type AtomicDynamicProfileRepository interface { + UpdateAgentProfileWithDynamic( + ctx context.Context, + profile *models.AgentProfile, + dynamic *models.DynamicAgentProfile, + expectedVersion int64, + routes []models.DynamicAgentRoute, + ) error +} diff --git a/apps/backend/internal/backendapp/dynamic_routing.go b/apps/backend/internal/backendapp/dynamic_routing.go new file mode 100644 index 0000000000..cc6b8a0d08 --- /dev/null +++ b/apps/backend/internal/backendapp/dynamic_routing.go @@ -0,0 +1,299 @@ +package backendapp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/kandev/kandev/internal/agent/agents" + agentruntime "github.com/kandev/kandev/internal/agent/runtime" + dynamicruntime "github.com/kandev/kandev/internal/agent/runtime/dynamic" + settingsstore "github.com/kandev/kandev/internal/agent/settings/store" + "github.com/kandev/kandev/internal/orchestrator" + "github.com/kandev/kandev/internal/task/models" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" +) + +// dynamicRouteActionHandler owns the complete route action transaction: +// selection, durable attribution, successor launch, and the final recovery +// state when launch fails. The optional launcher keeps focused handler tests +// able to exercise the durable selection boundary without constructing the +// full orchestrator. +func dynamicRouteActionHandler( + repo *sqliterepo.Repository, + profileRepo settingsstore.Repository, + resolver *agentruntime.ProfileExecutionResolver, + launchers ...func(context.Context, string) error, +) func(context.Context, orchestrator.RouteActionRequest) (*orchestrator.RouteActionResult, error) { + var launchSuccessor func(context.Context, string) error + if len(launchers) > 0 { + launchSuccessor = launchers[0] + } + return func(ctx context.Context, request orchestrator.RouteActionRequest) (*orchestrator.RouteActionResult, error) { + return applyDynamicRouteAction(ctx, repo, profileRepo, resolver, launchSuccessor, request) + } +} + +func applyDynamicRouteAction( + ctx context.Context, + repo *sqliterepo.Repository, + profileRepo settingsstore.Repository, + resolver *agentruntime.ProfileExecutionResolver, + launchSuccessor func(context.Context, string) error, + request orchestrator.RouteActionRequest, +) (*orchestrator.RouteActionResult, error) { + session, err := loadDynamicRouteActionSession(ctx, repo, profileRepo, resolver, request.SessionID) + if err != nil { + return nil, err + } + decision, err := resolveDynamicRouteAction(ctx, resolver, request, session) + if err != nil { + return handleDynamicRouteActionError(ctx, repo, session, err) + } + if err := persistDynamicRouteSelection(ctx, repo, session, decision); err != nil { + return nil, err + } + if request.Action == orchestrator.RouteActionCancelWait || request.Action == orchestrator.RouteActionStop { + return routeActionResult(ctx, repo, session), nil + } + return finishDynamicRouteAction(ctx, repo, session.ID, launchSuccessor) +} + +func loadDynamicRouteActionSession( + ctx context.Context, + repo *sqliterepo.Repository, + profileRepo settingsstore.Repository, + resolver *agentruntime.ProfileExecutionResolver, + sessionID string, +) (*models.TaskSession, error) { + if repo == nil || profileRepo == nil || resolver == nil { + return nil, errors.New("dynamic route actions are not configured") + } + session, err := repo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, err + } + profile, err := profileRepo.GetAgentProfile(ctx, session.AgentProfileID) + if err != nil { + return nil, err + } + agent, err := profileRepo.GetAgent(ctx, profile.AgentID) + if err != nil { + return nil, err + } + if agent.Name != agents.DynamicAgentID { + return nil, errors.New("route actions require a dynamic profile") + } + return session, nil +} + +func resolveDynamicRouteAction( + ctx context.Context, + resolver *agentruntime.ProfileExecutionResolver, + request orchestrator.RouteActionRequest, + session *models.TaskSession, +) (agentruntime.ProfileExecution, error) { + return resolver.ResolveRouteAction( + ctx, + session.ID, + session.AgentProfileID, + session.ExecutionProfileID, + request.ExpectedGeneration, + string(request.Action), + ) +} + +func handleDynamicRouteActionError( + ctx context.Context, + repo *sqliterepo.Repository, + session *models.TaskSession, + err error, +) (*orchestrator.RouteActionResult, error) { + var noCandidate *dynamicruntime.NoEligibleCandidateError + if !errors.As(err, &noCandidate) { + return nil, routeActionError(ctx, repo, session, err) + } + session.RouteGeneration = noCandidate.Generation + session.RouteState = "waiting" + session.RouteReason = "no_eligible_candidate" + session.DownstreamACPSessionID = "" + if updateErr := repo.UpdateTaskSession(ctx, session); updateErr != nil { + return nil, routeActionPersistenceError(ctx, repo, session, updateErr) + } + return routeActionResult(ctx, repo, session), nil +} + +func persistDynamicRouteSelection( + ctx context.Context, + repo *sqliterepo.Repository, + session *models.TaskSession, + decision agentruntime.ProfileExecution, +) error { + session.ExecutionProfileID = decision.ExecutionProfileID + session.RouteGeneration = decision.Generation + session.RouteState = decision.Decision.Status + if session.RouteState == "" { + session.RouteState = "starting" + } + session.RouteReason = decision.Decision.Reason + session.RouteErrorCode = string(decision.Decision.ErrorCode) + session.RouteErrorClass = string(decision.Decision.ErrorClass) + session.RouteCatalogueVersion = decision.Decision.CatalogueVersion + session.RouteRetryOrdinal = decision.Decision.RetryOrdinal + session.RoutePendingOutcome = string(decision.Decision.PendingOutcome) + session.RouteDeadline = decision.Decision.Deadline + // A candidate change never carries a provider-native ACP identity across + // profiles. The conductor will populate this after a fresh launch. + session.DownstreamACPSessionID = "" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + return routeActionPersistenceError(ctx, repo, session, err) + } + return nil +} + +func finishDynamicRouteAction( + ctx context.Context, + repo *sqliterepo.Repository, + sessionID string, + launchSuccessor func(context.Context, string) error, +) (*orchestrator.RouteActionResult, error) { + if launchSuccessor == nil { + session, err := repo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, err + } + return routeActionResult(ctx, repo, session), nil + } + if err := launchSuccessor(ctx, sessionID); err != nil { + return recoverDynamicRouteAction(ctx, repo, sessionID, err) + } + session, err := repo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, err + } + return routeActionResult(ctx, repo, session), nil +} + +func recoverDynamicRouteAction( + ctx context.Context, + repo *sqliterepo.Repository, + sessionID string, + launchErr error, +) (*orchestrator.RouteActionResult, error) { + session, err := repo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, err + } + session.RouteState = "action_required" + session.RouteReason = "route_action_launch_failed" + session.State = models.TaskSessionStateWaitingForInput + session.ErrorMessage = launchErr.Error() + session.DownstreamACPSessionID = "" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + return nil, routeActionPersistenceError(ctx, repo, session, err) + } + return routeActionResult(ctx, repo, session), nil +} + +func routeActionResult( + ctx context.Context, + repo *sqliterepo.Repository, + session *models.TaskSession, +) *orchestrator.RouteActionResult { + result := &orchestrator.RouteActionResult{ + SessionID: session.ID, + LogicalProfileID: session.AgentProfileID, + ExecutionProfileID: session.ExecutionProfileID, + RouteGeneration: session.RouteGeneration, + State: session.RouteState, + Reason: session.RouteReason, + ErrorCode: session.RouteErrorCode, + ErrorClass: session.RouteErrorClass, + CatalogueVersion: session.RouteCatalogueVersion, + RetryOrdinal: session.RouteRetryOrdinal, + Deadline: session.RouteDeadline, + PendingOutcome: session.RoutePendingOutcome, + } + if repo != nil { + if state, err := repo.LoadRouteState(ctx, session.ID); err == nil && state != nil { + result.ProfileVersion = state.ProfileVersion + var policyState dynamicruntime.PolicyState + if jsonErr := json.Unmarshal([]byte(state.PolicyStateJSON), &policyState); jsonErr == nil { + result.ErrorCode = string(policyState.FailureCode) + result.ErrorClass = string(policyState.FailureClass) + result.CatalogueVersion = policyState.CatalogueVersion + result.RetryOrdinal = policyState.RetryOrdinal + result.Deadline = policyState.Deadline + result.PendingOutcome = string(policyState.PendingOutcome) + } + } + } + return result +} + +func routeActionError( + ctx context.Context, + repo *sqliterepo.Repository, + session *models.TaskSession, + err error, +) error { + if !errors.Is(err, dynamicruntime.ErrStaleGeneration) { + return err + } + return routeActionConflict(ctx, repo, session, err) +} + +// routeActionPersistenceError reports the durable route state when the +// session projection cannot be updated after the engine has claimed a +// generation. The caller must not retry against the old session generation. +func routeActionPersistenceError( + ctx context.Context, + repo *sqliterepo.Repository, + session *models.TaskSession, + err error, +) error { + return routeActionConflict(ctx, repo, session, fmt.Errorf("persist route action session state: %w", err)) +} + +func routeActionConflict( + ctx context.Context, + repo *sqliterepo.Repository, + session *models.TaskSession, + err error, +) error { + state, loadErr := repo.LoadRouteState(ctx, session.ID) + if loadErr != nil { + return fmt.Errorf("%w; load authoritative route state: %v", err, loadErr) + } + result := &orchestrator.RouteActionResult{ + SessionID: session.ID, + LogicalProfileID: session.AgentProfileID, + ExecutionProfileID: session.ExecutionProfileID, + RouteGeneration: session.RouteGeneration, + State: session.RouteState, + ErrorCode: session.RouteErrorCode, + ErrorClass: session.RouteErrorClass, + CatalogueVersion: session.RouteCatalogueVersion, + RetryOrdinal: session.RouteRetryOrdinal, + Deadline: session.RouteDeadline, + PendingOutcome: session.RoutePendingOutcome, + } + if state != nil { + result.ExecutionProfileID = state.ExecutionProfileID + result.RouteGeneration = state.Generation + result.ProfileVersion = state.ProfileVersion + result.State = state.Status + result.LogicalProfileID = state.LogicalProfileID + var policyState dynamicruntime.PolicyState + if jsonErr := json.Unmarshal([]byte(state.PolicyStateJSON), &policyState); jsonErr == nil { + result.ErrorCode = string(policyState.FailureCode) + result.ErrorClass = string(policyState.FailureClass) + result.CatalogueVersion = policyState.CatalogueVersion + result.RetryOrdinal = policyState.RetryOrdinal + result.Deadline = policyState.Deadline + result.PendingOutcome = string(policyState.PendingOutcome) + } + } + return &orchestrator.RouteActionConflictError{Result: result, Err: err} +} diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 47759ac506..63e1b1592d 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -592,6 +592,13 @@ func startAgentInfrastructure( return false } orchestratorSvc.SetAgentctlBinaryPath(agentctlBinaryPath) + orchestratorSvc.SetRouteActionHandler(dynamicRouteActionHandler( + repos.Task, + repos.AgentSettings, + services.DynamicProfileResolver, + orchestratorSvc.LaunchDynamicRouteAction, + )) + orchestratorSvc.SetProfileExecutionResolver(services.DynamicProfileResolver) // Wire the soft-deleted-profile pre-flight into the watcher dispatch. // Orphan watchers (their agent profile was soft-deleted by the diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 1398a60614..c571e9c597 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -12,10 +12,13 @@ import ( "go.uber.org/zap" + "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/discovery" "github.com/kandev/kandev/internal/agent/hostutility" "github.com/kandev/kandev/internal/agent/managedruntime" "github.com/kandev/kandev/internal/agent/registry" + agentruntime "github.com/kandev/kandev/internal/agent/runtime" + dynamicruntime "github.com/kandev/kandev/internal/agent/runtime/dynamic" agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" agentsettingsmodels "github.com/kandev/kandev/internal/agent/settings/models" analyticsservice "github.com/kandev/kandev/internal/analytics/service" @@ -69,6 +72,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories } userSecretStore := secrets.NewUserVisibleStore(repos.Secrets) agentSettingsController := agentsettingscontroller.NewController(repos.AgentSettings, discoveryRegistry, agentRegistry, repos.Task, log) + agentSettingsController.SetDynamicAgentRoutingEnabled(cfg.Features.DynamicAgentRouting) agentSettingsController.SetSecretStore(userSecretStore) managedRuntimeSettings, err := systemsettings.NewStore(dbPool) if err != nil { @@ -82,9 +86,36 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories promptSvc := promptservice.NewService(repos.Prompts) utilitySvc := utilityservice.NewService(repos.Utility) utilitySvc.SetProfileResolver(profilebinding.New(repos.AgentSettings, func(agentID string) bool { + if agentID == agents.DynamicAgentID { + return cfg.Features.DynamicAgentRouting + } _, ok := agentRegistry.GetInferenceAgent(agentID) return ok })) + dynamicCircuits := dynamicruntime.NewCircuitRegistry( + dynamicruntime.WithCircuitPersistence(repos.Task), + ) + if err := dynamicCircuits.Restore(context.Background()); err != nil { + return nil, nil, fmt.Errorf("restore dynamic routing health: %w", err) + } + dynamicEngine := dynamicruntime.NewEngine( + dynamicruntime.WithPersistence(repos.Task), + dynamicruntime.WithStateLoader(repos.Task), + dynamicruntime.WithCircuitRegistry(dynamicCircuits), + ) + dynamicBindingResolver, err := dynamicruntime.NewPersistentCredentialBindingResolver( + context.Background(), repos.Task, + ) + if err != nil { + return nil, nil, fmt.Errorf("initialize dynamic routing installation key: %w", err) + } + dynamicResolver := agentruntime.NewProfileExecutionResolver( + repos.AgentSettings, + dynamicEngine, + cfg.Features.DynamicAgentRouting, + ) + dynamicResolver.SetCredentialBindingResolver(dynamicBindingResolver) + utilitySvc.SetExecutionProfileResolver(dynamicResolver) workflowSvc := workflowservice.NewService(repos.Workflow, log) pendingActionProjectionEpoch, err := repos.Task.NextPendingActionProjectionEpoch(context.Background()) if err != nil { @@ -253,6 +284,8 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories services := &Services{ ManagedRuntimeSelections: managedRuntimeSelections, + DynamicProfileResolver: dynamicResolver, + DynamicBindingResolver: dynamicBindingResolver, Task: taskSvc, User: userSvc, Editor: editorSvc, diff --git a/apps/backend/internal/backendapp/types.go b/apps/backend/internal/backendapp/types.go index c0671ebf31..0f684be8e3 100644 --- a/apps/backend/internal/backendapp/types.go +++ b/apps/backend/internal/backendapp/types.go @@ -4,6 +4,8 @@ import ( "errors" "github.com/kandev/kandev/internal/agent/managedruntime" + agentruntime "github.com/kandev/kandev/internal/agent/runtime" + dynamicruntime "github.com/kandev/kandev/internal/agent/runtime/dynamic" settingsstore "github.com/kandev/kandev/internal/agent/settings/store" analyticsrepository "github.com/kandev/kandev/internal/analytics/repository" authservice "github.com/kandev/kandev/internal/auth" @@ -68,6 +70,8 @@ type Repositories struct { type Services struct { ManagedRuntimeSelections managedruntime.SelectionStore + DynamicProfileResolver *agentruntime.ProfileExecutionResolver + DynamicBindingResolver *dynamicruntime.CredentialBindingResolver Task *taskservice.Service User *userservice.Service Editor *editorservice.Service diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index 983c64b4f1..c44f3bdd69 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -442,6 +442,11 @@ type FeaturesConfig struct { // opts in. Auth bool `mapstructure:"auth" json:"auth"` + // DynamicAgentRouting gates dynamic profile configuration, execution + // routing, and the shared route health service. It is disabled in every + // embedded profile until the complete feature is ready. + DynamicAgentRouting bool `mapstructure:"dynamic_agent_routing" json:"dynamicAgentRouting"` + // ClaudeBackgroundPromptHandoff gates the high-risk experiment that lets a // claude-acp session accept a successor prompt after an adapter-attested // foreground handoff while background work remains live. It is off in every diff --git a/apps/backend/internal/office/routing/resolver.go b/apps/backend/internal/office/routing/resolver.go index 120df0d352..52339f3c84 100644 --- a/apps/backend/internal/office/routing/resolver.go +++ b/apps/backend/internal/office/routing/resolver.go @@ -6,6 +6,7 @@ import ( "time" "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/agent/runtime/routingerr" settingsmodels "github.com/kandev/kandev/internal/agent/settings/models" "github.com/kandev/kandev/internal/office/models" ) @@ -43,24 +44,10 @@ const ( SkipReasonMissingModelMapping = "missing_model_mapping" ) -// autoRetryableCodes is the allow-list of error codes that mark a -// degraded provider as "the scheduler will retry on its own." Anything -// outside this set is user-actionable. Referenced again by Phase 4 -// (block-reason aggregation, wake-up scheduling). -var autoRetryableCodes = map[string]struct{}{ - "rate_limited": {}, - "quota_limited": {}, - "network_unavailable": {}, - "provider_unavailable": {}, - "provider_overloaded": {}, - "model_capacity": {}, - "unknown_provider_error": {}, -} - -// IsAutoRetryableCode reports whether code is in the auto-retry allow-list. +// IsAutoRetryableCode derives the legacy Office health hint from the shared +// catalogue. Unknown and non-provider codes fail closed. func IsAutoRetryableCode(code string) bool { - _, ok := autoRetryableCodes[code] - return ok + return routingerr.ClassForCode(routingerr.Code(code)) == routingerr.ClassTransient } // Repo is the narrow interface the resolver needs over the sqlite repo. diff --git a/apps/backend/internal/office/scheduler/dispatch_routing.go b/apps/backend/internal/office/scheduler/dispatch_routing.go index 923141d7b6..6d2dafb174 100644 --- a/apps/backend/internal/office/scheduler/dispatch_routing.go +++ b/apps/backend/internal/office/scheduler/dispatch_routing.go @@ -280,7 +280,7 @@ func (ss *SchedulerService) tryCandidates( } ss.recordRouteAttempt(agent.WorkspaceID, string(candidate.ProviderID), outcome, string(classified.Code)) - if routingerr.Decide(routingerr.ContextOffice, classified, time.Now().UTC()) == routingerr.DecisionShortRetry { + if officeShortRetryAllowed(classified) { retryCount, countErr := ss.shortRouteRetryCount(ctx, run, candidate) if countErr != nil { return false, nil, countErr diff --git a/apps/backend/internal/office/scheduler/routing_lifecycle.go b/apps/backend/internal/office/scheduler/routing_lifecycle.go index e40511a902..3378f76a97 100644 --- a/apps/backend/internal/office/scheduler/routing_lifecycle.go +++ b/apps/backend/internal/office/scheduler/routing_lifecycle.go @@ -54,7 +54,7 @@ func (ss *SchedulerService) HandlePostStartFailure( if !classified.FallbackAllowed { return false, nil } - if routingerr.Decide(routingerr.ContextOffice, classified, time.Now().UTC()) == routingerr.DecisionShortRetry { + if officeShortRetryAllowed(classified) { retryCount, err := ss.shortRouteRetryCount(ctx, run, candidate) if err != nil { return false, err @@ -72,6 +72,15 @@ func (ss *SchedulerService) HandlePostStartFailure( return true, nil } +// officeShortRetryAllowed consumes the shared provider-error class rather +// than maintaining an Office-only code allow-list. Hard errors fall through +// to candidate switching by default; a selected dynamic policy can add a +// retry through the shared runtime evaluator. +func officeShortRetryAllowed(classified *routingerr.Error) bool { + return classified != nil && classified.FallbackAllowed && + classified.Class == routingerr.ClassTransient && classified.AutoRetryable +} + const officeShortRetryMaxAttempts = 3 var officeShortRetryBackoff = []time.Duration{ diff --git a/apps/backend/internal/orchestrator/dynamic_evidence.go b/apps/backend/internal/orchestrator/dynamic_evidence.go new file mode 100644 index 0000000000..a803cd5829 --- /dev/null +++ b/apps/backend/internal/orchestrator/dynamic_evidence.go @@ -0,0 +1,127 @@ +package orchestrator + +import ( + "sync" + + "github.com/kandev/kandev/internal/orchestrator/watcher" +) + +// dynamicAttemptEvidence is deliberately process-local. It is only an +// observation fence for the current downstream execution; the durable +// continuation package is stored with the route generation separately. +type dynamicAttemptEvidence struct { + mu sync.Mutex + executionID string + evidenceKnown bool + output bool + effect bool +} + +func (s *Service) beginDynamicAttempt(sessionID string) { + if sessionID == "" { + return + } + s.dynamicAttemptEvidence.Store(sessionID, &dynamicAttemptEvidence{evidenceKnown: true}) +} + +func (s *Service) bindDynamicAttemptExecution(sessionID, executionID string) { + if sessionID == "" || executionID == "" { + return + } + v, ok := s.dynamicAttemptEvidence.Load(sessionID) + if !ok { + return + } + evidence, ok := v.(*dynamicAttemptEvidence) + if !ok { + return + } + evidence.mu.Lock() + evidence.executionID = executionID + evidence.mu.Unlock() +} + +func (s *Service) observeDynamicAttempt(sessionID, executionID string, output, effect bool) { + if sessionID == "" || (!output && !effect) { + return + } + v, ok := s.dynamicAttemptEvidence.Load(sessionID) + if !ok { + return + } + evidence, ok := v.(*dynamicAttemptEvidence) + if !ok { + return + } + evidence.mu.Lock() + defer evidence.mu.Unlock() + if evidence.executionID != "" && executionID != "" && evidence.executionID != executionID { + return + } + if evidence.executionID != "" && executionID == "" { + // Once a concrete execution is bound, an observation without its + // execution identity is ambiguous. Keep the route gate fail-closed. + evidence.evidenceKnown = false + return + } + if evidence.executionID == "" && executionID != "" { + evidence.executionID = executionID + } + evidence.output = evidence.output || output + evidence.effect = evidence.effect || effect +} + +func (s *Service) withDynamicAttemptEvidence(data watcher.AgentEventData) watcher.AgentEventData { + if data.SessionID == "" { + return data + } + v, ok := s.dynamicAttemptEvidence.Load(data.SessionID) + if !ok { + return data + } + evidence, ok := v.(*dynamicAttemptEvidence) + if !ok { + return data + } + evidence.mu.Lock() + defer evidence.mu.Unlock() + data.DynamicRouteAttempt = true + if evidence.executionID != "" && + (data.AgentExecutionID == "" || evidence.executionID != data.AgentExecutionID) { + // The event belongs to a predecessor or successor that is not the + // attempt represented by this evidence record. Keep the route gate + // fail-closed and let the durable execution fence reject it too. + data.EvidenceKnown = false + data.OutputObserved = false + data.EffectObserved = false + return data + } + data.EvidenceKnown = evidence.evidenceKnown + data.OutputObserved = evidence.output + data.EffectObserved = evidence.effect + return data +} + +func (s *Service) clearDynamicAttemptEvidence(sessionID, executionID string) { + if sessionID == "" || executionID == "" { + return + } + v, ok := s.dynamicAttemptEvidence.Load(sessionID) + if !ok { + return + } + evidence, ok := v.(*dynamicAttemptEvidence) + if !ok { + return + } + evidence.mu.Lock() + currentExecutionID := evidence.executionID + evidence.mu.Unlock() + if currentExecutionID == executionID { + s.dynamicAttemptEvidence.CompareAndDelete(sessionID, evidence) + } +} + +func dynamicPreResultSafe(data watcher.AgentEventData) bool { + return data.DynamicRouteAttempt && data.EvidenceKnown && !data.OutputObserved && !data.EffectObserved +} diff --git a/apps/backend/internal/orchestrator/dynamic_evidence_test.go b/apps/backend/internal/orchestrator/dynamic_evidence_test.go new file mode 100644 index 0000000000..ca2314e22e --- /dev/null +++ b/apps/backend/internal/orchestrator/dynamic_evidence_test.go @@ -0,0 +1,69 @@ +package orchestrator + +import ( + "testing" + + "github.com/kandev/kandev/internal/orchestrator/watcher" +) + +func TestDynamicPreResultRequiresExplicitKnownEvidence(t *testing.T) { + if dynamicPreResultSafe(watcher.AgentEventData{DynamicRouteAttempt: true}) { + t.Fatal("unknown dynamic attempt was treated as pre-result safe") + } + if !dynamicPreResultSafe(watcher.AgentEventData{ + DynamicRouteAttempt: true, + EvidenceKnown: true, + }) { + t.Fatal("known no-output/no-effect attempt was not pre-result safe") + } + if dynamicPreResultSafe(watcher.AgentEventData{ + DynamicRouteAttempt: true, + EvidenceKnown: true, + OutputObserved: true, + }) { + t.Fatal("output-producing attempt was treated as pre-result safe") + } + if dynamicPreResultSafe(watcher.AgentEventData{ + DynamicRouteAttempt: true, + EvidenceKnown: true, + EffectObserved: true, + }) { + t.Fatal("effect-producing attempt was treated as pre-result safe") + } +} + +func TestDynamicAttemptEvidenceRejectsAmbiguousExecutionEvents(t *testing.T) { + var service Service + service.beginDynamicAttempt("session-1") + service.bindDynamicAttemptExecution("session-1", "execution-1") + + service.observeDynamicAttempt("session-1", "", true, false) + got := service.withDynamicAttemptEvidence(watcher.AgentEventData{ + SessionID: "session-1", + AgentExecutionID: "execution-1", + DynamicRouteAttempt: true, + }) + if got.EvidenceKnown { + t.Fatal("missing execution identity did not invalidate evidence") + } + if dynamicPreResultSafe(got) { + t.Fatal("ambiguous execution event was treated as pre-result safe") + } +} + +func TestDynamicAttemptEvidenceRejectsStaleExecution(t *testing.T) { + var service Service + service.beginDynamicAttempt("session-1") + service.bindDynamicAttemptExecution("session-1", "execution-2") + + got := service.withDynamicAttemptEvidence(watcher.AgentEventData{ + SessionID: "session-1", + AgentExecutionID: "execution-1", + }) + if got.EvidenceKnown { + t.Fatal("stale execution was accepted by evidence fence") + } + if dynamicPreResultSafe(got) { + t.Fatal("stale execution was treated as pre-result safe") + } +} diff --git a/apps/backend/internal/orchestrator/dynamic_launch.go b/apps/backend/internal/orchestrator/dynamic_launch.go new file mode 100644 index 0000000000..0a1f2de9e3 --- /dev/null +++ b/apps/backend/internal/orchestrator/dynamic_launch.go @@ -0,0 +1,684 @@ +package orchestrator + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + + dynamicruntime "github.com/kandev/kandev/internal/agent/runtime/dynamic" + "github.com/kandev/kandev/internal/agent/runtime/routingerr" + "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/orchestrator/watcher" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" + "go.uber.org/zap" +) + +const dynamicRouteStatusWaiting = "waiting" + +// dynamicTaskDownstream adapts the task executor to the provider-neutral +// conductor. The callback updates the task-session attribution before every +// concrete launch, including a cross-profile fallback. +type dynamicTaskDownstream struct { + service *Service + task *v1.Task + sessionID string + options executor.LaunchOptions + execution *executor.TaskExecution +} + +func (d *dynamicTaskDownstream) Launch( + ctx context.Context, + launch dynamicruntime.DownstreamLaunch, +) (dynamicruntime.DownstreamExecution, error) { + if err := d.service.persistDynamicLaunchDecision(ctx, d.sessionID, launch.Decision); err != nil { + return dynamicruntime.DownstreamExecution{}, err + } + options := d.options + options.AgentProfileID = launch.ExecutionProfileID + options.Prompt = launch.Prompt + options.PriorACPSession = launch.PriorACPSession + d.service.beginDynamicAttempt(d.sessionID) + execution, err := d.service.executor.LaunchPreparedSession(ctx, d.task, d.sessionID, options) + if err != nil { + var classified *routingerr.Error + if errors.As(err, &classified) { + return dynamicruntime.DownstreamExecution{}, err + } + classified = routingerr.Classify(routingerr.Input{ + Phase: routingerr.PhaseProcessStart, + ProviderID: launch.ExecutionProfileID, + Stderr: err.Error(), + }) + // Unknown low-confidence launch failures are workspace/runtime errors, + // not provider failures. Let the ordinary launch recovery own them. + if classified.Confidence == routingerr.ConfLow { + return dynamicruntime.DownstreamExecution{}, err + } + return dynamicruntime.DownstreamExecution{}, fmt.Errorf("%w: %v", classified, err) + } + d.service.bindDynamicAttemptExecution(d.sessionID, execution.AgentExecutionID) + d.execution = execution + acpSessionID := "" + if session, sessionErr := d.service.repo.GetTaskSession(ctx, d.sessionID); sessionErr == nil && session != nil { + acpSessionID = session.DownstreamACPSessionID + } + if acpSessionID != "" { + if err := d.service.persistDynamicACPSession(ctx, d.sessionID, launch.Decision, acpSessionID); err != nil { + return dynamicruntime.DownstreamExecution{}, err + } + } + return dynamicruntime.DownstreamExecution{ + ID: execution.AgentExecutionID, + ExecutionProfileID: launch.ExecutionProfileID, + ACPSessionID: acpSessionID, + }, nil +} + +func (d *dynamicTaskDownstream) LoadExecution( + ctx context.Context, + sessionID string, +) (dynamicruntime.DownstreamExecution, bool, error) { + if sessionID == "" { + return dynamicruntime.DownstreamExecution{}, false, nil + } + session, err := d.service.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return dynamicruntime.DownstreamExecution{}, false, err + } + if session == nil || session.AgentExecutionID == "" { + return dynamicruntime.DownstreamExecution{}, false, nil + } + return dynamicruntime.DownstreamExecution{ + ID: session.AgentExecutionID, + ExecutionProfileID: session.ExecutionProfileID, + ACPSessionID: session.DownstreamACPSessionID, + }, true, nil +} + +func (d *dynamicTaskDownstream) Resume(context.Context, string, string) error { + return errors.New("dynamic task downstream resume is owned by the orchestrator") +} + +func (d *dynamicTaskDownstream) Stop(context.Context, string, string) error { + return nil +} + +func (s *Service) persistDynamicLaunchDecision( + ctx context.Context, + sessionID string, + decision dynamicruntime.RouteDecision, +) error { + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return err + } + if session.ExecutionProfileID == decision.ExecutionProfileID && + session.RouteGeneration == decision.Generation && + decision.Status == "" { + return nil + } + previousExecutionProfileID := session.ExecutionProfileID + session.ExecutionProfileID = decision.ExecutionProfileID + session.RouteGeneration = decision.Generation + session.RouteState = decision.Status + if session.RouteState == "" { + session.RouteState = "starting" + } + session.RouteReason = decision.Reason + applyDynamicRouteDecisionProjection(session, decision) + // The executor marks a failed provider launch terminal before the + // conductor can claim the next candidate. Re-open the logical session for + // that immediate retry so the second launch can persist STARTING state. + if session.State == models.TaskSessionStateFailed { + session.State = models.TaskSessionStateCreated + session.ErrorMessage = "" + } + if previousExecutionProfileID != decision.ExecutionProfileID { + session.DownstreamACPSessionID = "" + } + return s.repo.UpdateTaskSession(ctx, session) +} + +func (s *Service) persistDynamicACPSession( + ctx context.Context, + sessionID string, + decision dynamicruntime.RouteDecision, + acpSessionID string, +) error { + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return err + } + if session == nil || session.RouteGeneration != decision.Generation || + session.ExecutionProfileID != decision.ExecutionProfileID { + return dynamicruntime.ErrStaleGeneration + } + if session.DownstreamACPSessionID == acpSessionID { + return nil + } + session.DownstreamACPSessionID = acpSessionID + return s.repo.UpdateTaskSession(ctx, session) +} + +func applyDynamicRouteDecisionProjection( + session *models.TaskSession, + decision dynamicruntime.RouteDecision, +) { + if session == nil { + return + } + session.RouteErrorCode = string(decision.ErrorCode) + session.RouteErrorClass = string(decision.ErrorClass) + session.RouteCatalogueVersion = decision.CatalogueVersion + session.RouteRetryOrdinal = decision.RetryOrdinal + session.RoutePendingOutcome = string(decision.PendingOutcome) + session.RouteDeadline = nil + if decision.Deadline != nil { + deadline := decision.Deadline.UTC() + session.RouteDeadline = &deadline + } +} + +// launchPreparedSessionWithDynamicFallback keeps the logical session stable +// while delegating classified pre-result provider fallback to dynamic.Conductor. +// Concrete profiles continue through the ordinary executor path. +func (s *Service) launchPreparedSessionWithDynamicFallback( + ctx context.Context, + task *v1.Task, + sessionID string, + options executor.LaunchOptions, +) (*executor.TaskExecution, error) { + return s.launchPreparedSessionWithDynamicFallbackWithContinuation(ctx, task, sessionID, options, nil) +} + +func (s *Service) launchPreparedSessionWithDynamicFallbackWithContinuation( + ctx context.Context, + task *v1.Task, + sessionID string, + options executor.LaunchOptions, + continuationInput *dynamicruntime.ContinuationInput, +) (*executor.TaskExecution, error) { + if s.profileExecutionResolver == nil { + return s.executor.LaunchPreparedSession(ctx, task, sessionID, options) + } + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, err + } + decision, dynamic, err := s.dynamicLaunchDecision(ctx, session) + if err != nil { + return nil, err + } + if !dynamic { + return s.executor.LaunchPreparedSession(ctx, task, sessionID, options) + } + downstream := &dynamicTaskDownstream{ + service: s, task: task, sessionID: sessionID, options: options, + } + conductor := s.profileExecutionResolver.NewConductor(downstream) + prebuiltContinuation, continuationInput, err := s.dynamicContinuationForLaunch( + ctx, task, sessionID, options.Prompt, continuationInput, + ) + if err != nil { + return nil, err + } + selected := dynamicruntime.ConductorSelectedLaunch{ + SessionID: session.ID, LogicalProfileID: session.AgentProfileID, + Decision: decision, Prompt: options.Prompt, + PriorACPSession: session.DownstreamACPSessionID, + } + if prebuiltContinuation != nil { + selected.PrebuiltContinuation = prebuiltContinuation + } else { + selected.Continuation = *continuationInput + } + result, err := conductor.LaunchSelected(ctx, selected) + if err != nil { + return nil, err + } + if downstream.execution == nil { + return nil, errors.New("dynamic conductor returned no task execution") + } + if result.Execution.ExecutionProfileID == "" { + result.Execution.ExecutionProfileID = session.ExecutionProfileID + } + return downstream.execution, nil +} + +func (s *Service) dynamicContinuationForLaunch( + ctx context.Context, + task *v1.Task, + sessionID, prompt string, + continuationInput *dynamicruntime.ContinuationInput, +) (*dynamicruntime.Continuation, *dynamicruntime.ContinuationInput, error) { + if loader, ok := s.repo.(dynamicRouteStateLoader); ok { + state, err := loader.LoadRouteState(ctx, sessionID) + if err != nil { + return nil, nil, err + } + if state != nil && state.ContinuationJSON != "" { + var continuation dynamicruntime.Continuation + if err := json.Unmarshal([]byte(state.ContinuationJSON), &continuation); err != nil { + return nil, nil, fmt.Errorf("decode dynamic continuation: %w", err) + } + return &continuation, nil, nil + } + } + if continuationInput != nil { + return nil, continuationInput, nil + } + input, err := s.buildDynamicContinuation(ctx, task, sessionID, prompt, "") + if err != nil { + return nil, nil, err + } + return nil, &input, nil +} + +func (s *Service) buildDynamicContinuation( + ctx context.Context, + task *v1.Task, + sessionID, prompt, failureReason string, +) (dynamicruntime.ContinuationInput, error) { + if task == nil || sessionID == "" { + return dynamicruntime.ContinuationInput{}, errors.New("dynamic continuation requires task and session") + } + input := dynamicruntime.ContinuationInput{ + TaskDescription: task.Description, + FailureReason: failureReason, + } + if strings.TrimSpace(prompt) != "" { + input.UserMessages = append(input.UserMessages, prompt) + } + if err := s.addDynamicTaskMetadata(ctx, task, &input); err != nil { + return dynamicruntime.ContinuationInput{}, err + } + if err := s.addDynamicConversation(ctx, sessionID, &input); err != nil { + return dynamicruntime.ContinuationInput{}, err + } + if err := s.addDynamicPlan(ctx, task.ID, &input); err != nil { + return dynamicruntime.ContinuationInput{}, err + } + input.RepositorySummary = dynamicRepositorySummary(task) + return input, nil +} + +func (s *Service) addDynamicTaskMetadata(ctx context.Context, task *v1.Task, input *dynamicruntime.ContinuationInput) error { + dbTask, err := s.repo.GetTask(ctx, task.ID) + if err != nil { + return fmt.Errorf("load task for dynamic continuation: %w", err) + } + if dbTask != nil { + input.WorkflowStep = dbTask.WorkflowStepID + } + return nil +} + +func (s *Service) addDynamicConversation(ctx context.Context, sessionID string, input *dynamicruntime.ContinuationInput) error { + messages, err := s.repo.ListMessages(ctx, sessionID) + if err != nil { + return fmt.Errorf("load conversation for dynamic continuation: %w", err) + } + conversation := make([]string, 0, len(messages)) + toolSummary := make([]string, 0) + for _, message := range messages { + if message == nil || strings.TrimSpace(message.Content) == "" { + continue + } + content := strings.TrimSpace(message.Content) + if message.AuthorType == models.MessageAuthorUser { + input.UserMessages = append(input.UserMessages, content) + continue + } + switch message.Type { + case models.MessageTypeToolCall, models.MessageTypeToolEdit, + models.MessageTypeToolRead, models.MessageTypeToolExecute: + toolSummary = append(toolSummary, string(message.Type)+": "+content) + default: + conversation = append(conversation, string(message.AuthorType)+": "+content) + } + } + input.Conversation = strings.Join(conversation, "\n") + input.ToolSummary = strings.Join(toolSummary, "\n") + return nil +} + +func (s *Service) addDynamicPlan(ctx context.Context, taskID string, input *dynamicruntime.ContinuationInput) error { + plan, err := s.repo.GetTaskPlan(ctx, taskID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return fmt.Errorf("load plan for dynamic continuation: %w", err) + } + if plan != nil { + input.PlanSummary = strings.TrimSpace(plan.Title + "\n" + plan.Content) + } + return nil +} + +func dynamicRepositorySummary(task *v1.Task) string { + repositories := make([]string, 0, len(task.Repositories)+len(task.WorkspaceFolders)) + for _, repository := range task.Repositories { + repositories = append(repositories, repository.RepositoryID+" @ "+repository.BaseBranch) + } + for _, folder := range task.WorkspaceFolders { + repositories = append(repositories, "folder: "+folder.DisplayName+" @ "+folder.LocalPath) + } + return strings.Join(repositories, "\n") +} + +func (s *Service) dynamicLaunchDecision( + ctx context.Context, + session *models.TaskSession, +) (dynamicruntime.RouteDecision, bool, error) { + decision := dynamicruntime.RouteDecision{ + SessionID: session.ID, + LogicalProfileID: session.AgentProfileID, + ExecutionProfileID: session.ExecutionProfileID, + Generation: session.RouteGeneration, + Reason: session.RouteReason, + } + dynamic := decision.Generation > 0 && decision.ExecutionProfileID != "" + loader, ok := s.repo.(dynamicRouteStateLoader) + if !ok { + return decision, dynamic, nil + } + state, err := loader.LoadRouteState(ctx, session.ID) + if err != nil { + return dynamicruntime.RouteDecision{}, false, err + } + if state == nil || state.LogicalProfileID != session.AgentProfileID || state.Generation <= 0 { + return decision, dynamic, nil + } + if state.ExecutionProfileID == "" { + if state.Status != dynamicRouteStatusWaiting { + return dynamicruntime.RouteDecision{}, false, &dynamicruntime.NoEligibleCandidateError{ + SessionID: session.ID, LogicalProfile: session.AgentProfileID, + Generation: state.Generation, + } + } + resolved, err := s.profileExecutionResolver.Resolve( + ctx, session.ID, session.AgentProfileID, state.Generation, "", + ) + if err != nil { + return dynamicruntime.RouteDecision{}, false, err + } + decision = resolved.Decision + if err := s.persistDynamicLaunchDecision(ctx, session.ID, decision); err != nil { + return dynamicruntime.RouteDecision{}, false, fmt.Errorf("persist dynamic route attribution: %w", err) + } + return decision, true, nil + } + resolved, err := s.profileExecutionResolver.ResolveExisting( + ctx, session.ID, session.AgentProfileID, state.ExecutionProfileID, + state.Generation, state.ProfileVersion, "durable_route_state", + ) + if err != nil { + return dynamicruntime.RouteDecision{}, false, err + } + if session.ExecutionProfileID == resolved.ExecutionProfileID && + session.RouteGeneration == resolved.Generation { + return resolved.Decision, true, nil + } + applyResolvedExecution(session, resolved) + if err := s.repo.UpdateTaskSession(ctx, session); err != nil { + return dynamicruntime.RouteDecision{}, false, fmt.Errorf("persist dynamic route attribution: %w", err) + } + return resolved.Decision, true, nil +} + +// routeDynamicAgentFailure applies the configured action for a classified +// task failure. It is intentionally limited to dynamic sessions and provider +// errors that explicitly allow fallback; user/runtime errors keep the normal +// recovery surface. +func (s *Service) routeDynamicAgentFailure( + ctx context.Context, + data watcher.AgentEventData, + classified *routingerr.Error, +) bool { + if classified == nil || !classified.FallbackAllowed { + return false + } + data = s.withDynamicAttemptEvidence(data) + session, ok := s.dynamicFailureSession(ctx, data) + if !ok { + return false + } + if !dynamicPreResultSafe(data) { + // A dynamic route is never allowed to guess that a failed turn was + // pre-result. Missing evidence is as unsafe as observed output or a + // tool effect because the replacement could repeat a side effect. + return false + } + conductor := s.profileExecutionResolver.NewConductor(nil) + task, err := s.scheduler.GetTask(ctx, data.TaskID) + if err != nil { + return false + } + continuationInput, err := s.buildDynamicContinuation( + ctx, task, session.ID, "", classified.Error(), + ) + if err != nil { + return false + } + decision, err := conductor.RouteAfterFailure( + ctx, session.ID, session.AgentProfileID, session.ExecutionProfileID, + session.RouteGeneration, classified, + ) + if err != nil { + return s.persistPendingDynamicRecovery(ctx, session, decision, err) + } + return s.launchDynamicSuccessorAfterFailure(ctx, data, session, conductor, decision, continuationInput) +} + +func (s *Service) persistPendingDynamicRecovery( + ctx context.Context, + session *models.TaskSession, + decision dynamicruntime.RouteDecision, + err error, +) bool { + if !errors.Is(err, dynamicruntime.ErrRecoveryPending) { + return false + } + // The evaluator durably owns the retry/reset deadline. Keep the logical + // session available for the authoritative recovery surface; no successor + // launch is allowed until a due/manual action claims the same generation. + session.RouteState = decision.Status + session.RouteReason = decision.Reason + applyDynamicRouteDecisionProjection(session, decision) + session.State = models.TaskSessionStateWaitingForInput + session.DownstreamACPSessionID = "" + if updateErr := s.repo.UpdateTaskSession(ctx, session); updateErr != nil { + s.logger.Warn("failed to persist pending dynamic recovery", + zap.String("session_id", session.ID), zap.Error(updateErr)) + return false + } + s.schedulePersistedDynamicRecovery(ctx, session.ID, decision.Generation) + return true +} + +func (s *Service) schedulePersistedDynamicRecovery(ctx context.Context, sessionID string, generation int64) { + loader, ok := s.repo.(dynamicRouteStateLoader) + if !ok { + return + } + state, err := loader.LoadRouteState(ctx, sessionID) + if err != nil || state == nil { + return + } + s.scheduleDynamicPolicyRecovery(sessionID, generation, state.PolicyStateJSON) +} + +func (s *Service) launchDynamicSuccessorAfterFailure( + ctx context.Context, + data watcher.AgentEventData, + session *models.TaskSession, + conductor *dynamicruntime.Conductor, + decision dynamicruntime.RouteDecision, + continuationInput dynamicruntime.ContinuationInput, +) bool { + continuation, err := conductor.BuildContinuation(ctx, continuationInput) + if err != nil { + return false + } + if err := conductor.PersistContinuation(ctx, decision, continuation); err != nil { + return false + } + next, err := s.profileExecutionResolver.ResolveExisting( + ctx, session.ID, session.AgentProfileID, decision.ExecutionProfileID, + decision.Generation, decision.ProfileVersion, decision.Reason, + ) + if err != nil || next.ExecutionProfileID == "" { + return false + } + if err := s.persistDynamicLaunchDecision(ctx, session.ID, next.Decision); err != nil { + s.logger.Warn("failed to persist dynamic fallback attribution", + zap.String("session_id", session.ID), zap.Error(err)) + return false + } + return s.relaunchDynamicTaskAfterFailure(ctx, data, next.ExecutionProfileID) +} + +// LaunchDynamicRouteAction completes a manual retry/try-next operation after +// the backend route-action handler has claimed the successor generation. It +// owns the predecessor shutdown, continuation persistence, successor launch, +// and final launch error surface so the UI never has to issue a second launch +// request. +func (s *Service) LaunchDynamicRouteAction(ctx context.Context, sessionID string) error { + if s.profileExecutionResolver == nil || sessionID == "" { + return errors.New("dynamic route action launch is not configured") + } + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil || session == nil { + if err != nil { + return err + } + return errors.New("dynamic route action session not found") + } + task, err := s.scheduler.GetTask(ctx, session.TaskID) + if err != nil { + return err + } + input, err := s.buildDynamicContinuation(ctx, task, sessionID, "", "manual dynamic route action") + if err != nil { + return err + } + conductor := s.profileExecutionResolver.NewConductor(nil) + continuation, err := conductor.BuildContinuation(ctx, input) + if err != nil { + return err + } + decision := dynamicruntime.RouteDecision{ + SessionID: session.ID, + LogicalProfileID: session.AgentProfileID, + ExecutionProfileID: session.ExecutionProfileID, + Generation: session.RouteGeneration, + Reason: session.RouteReason, + } + if err := conductor.PersistContinuation(ctx, decision, continuation); err != nil { + return err + } + data := watcher.AgentEventData{ + TaskID: task.ID, + SessionID: session.ID, + AgentExecutionID: session.AgentExecutionID, + AgentProfileID: session.AgentProfileID, + ExecutionProfileID: session.ExecutionProfileID, + ErrorMessage: "manual dynamic route action", + } + if !s.relaunchDynamicTaskAfterFailure(ctx, data, session.ExecutionProfileID) { + return errors.New("dynamic route action successor launch failed") + } + return nil +} + +func (s *Service) dynamicFailureSession( + ctx context.Context, + data watcher.AgentEventData, +) (*models.TaskSession, bool) { + if s.profileExecutionResolver == nil || data.SessionID == "" { + return nil, false + } + session, err := s.repo.GetTaskSession(ctx, data.SessionID) + if err != nil || session == nil || session.RouteGeneration <= 0 || session.ExecutionProfileID == "" { + return nil, false + } + if session.AgentExecutionID != "" && data.AgentExecutionID != "" && + session.AgentExecutionID != data.AgentExecutionID { + return nil, false + } + return session, true +} + +func (s *Service) relaunchDynamicTaskAfterFailure( + ctx context.Context, + data watcher.AgentEventData, + executionProfileID string, +) bool { + prompt, ok := s.dynamicRelaunchPrompt(ctx, data.SessionID) + if !ok { + return false + } + task, err := s.scheduler.GetTask(ctx, data.TaskID) + if err != nil { + return false + } + session, err := s.repo.GetTaskSession(ctx, data.SessionID) + if err != nil || session == nil { + return false + } + if err := s.executor.StopExecution(ctx, data.AgentExecutionID, "dynamic route fallback", true); err != nil { + s.logger.Debug("failed to stop dynamic fallback predecessor", zap.Error(err)) + } + if err := s.repo.UpdateTaskSessionState(ctx, data.SessionID, models.TaskSessionStateCreated, ""); err != nil { + return false + } + s.completeTurnForSession(ctx, data.SessionID) + s.retireExecutionActivityAndPublish(ctx, data.TaskID, data.SessionID, data.AgentExecutionID) + isOfficeTask, officeErr := s.lookupOfficeTask(ctx, data.TaskID) + if officeErr == nil && !isOfficeTask { + _, err := s.StartCreatedSession( + ctx, data.TaskID, data.SessionID, session.AgentProfileID, + prompt.text, true, prompt.planMode, true, prompt.attachments, nil, + ) + return err == nil + } + _, err = s.launchPreparedSessionWithDynamicFallback(ctx, task, data.SessionID, executor.LaunchOptions{ + AgentProfileID: executionProfileID, + OfficeAgentProfileID: session.AgentProfileID, + ExecutorID: "", + Prompt: prompt.text, + StartAgent: true, + McpMode: executor.McpModeOffice, + }) + return err == nil +} + +// dynamicRelaunchPrompt prefers the in-memory prompt cache for an automatic +// fallback, but reconstructs the latest durable user prompt for a manual route +// action or a backend restart. A route action must not strand a claimed route +// merely because the process-local retry cache was lost. +func (s *Service) dynamicRelaunchPrompt(ctx context.Context, sessionID string) (capturedPrompt, bool) { + if value, ok := s.lastTurnPrompt.Load(sessionID); ok { + if prompt, ok := value.(capturedPrompt); ok && strings.TrimSpace(prompt.text) != "" { + return prompt, true + } + } + messages, err := s.repo.ListMessages(ctx, sessionID) + if err != nil { + return capturedPrompt{}, false + } + for index := len(messages) - 1; index >= 0; index-- { + message := messages[index] + if message == nil || message.AuthorType != models.MessageAuthorUser || + strings.TrimSpace(message.Content) == "" { + continue + } + return capturedPrompt{text: message.Content}, true + } + return capturedPrompt{}, false +} diff --git a/apps/backend/internal/orchestrator/dynamic_policy_recovery.go b/apps/backend/internal/orchestrator/dynamic_policy_recovery.go new file mode 100644 index 0000000000..82ef5df7d3 --- /dev/null +++ b/apps/backend/internal/orchestrator/dynamic_policy_recovery.go @@ -0,0 +1,220 @@ +package orchestrator + +import ( + "context" + "encoding/json" + "errors" + "time" + + agentruntime "github.com/kandev/kandev/internal/agent/runtime" + dynamicruntime "github.com/kandev/kandev/internal/agent/runtime/dynamic" + "github.com/kandev/kandev/internal/agent/runtime/routingpolicy" + "github.com/kandev/kandev/internal/task/models" + "go.uber.org/zap" +) + +type dynamicRouteStateLister interface { + ListPendingRouteStates(context.Context) ([]dynamicruntime.RouteState, error) +} + +// startDynamicPolicyRecovery restores only durable waits whose dispatch has +// not started. A persisted retrying state is intentionally left for manual +// recovery because a process restart cannot prove whether its launch crossed +// the provider boundary. +func (s *Service) startDynamicPolicyRecovery(ctx context.Context) { + if s.profileExecutionResolver == nil { + return + } + lister, ok := s.repo.(dynamicRouteStateLister) + if !ok { + return + } + recoveryCtx, cancel := context.WithCancel(context.Background()) + s.dynamicRecoveryMu.Lock() + s.dynamicRecoveryCtx = recoveryCtx + s.dynamicRecoveryCancel = cancel + if s.dynamicRecoveryTimers == nil { + s.dynamicRecoveryTimers = make(map[string]*time.Timer) + } + s.dynamicRecoveryMu.Unlock() + + states, err := lister.ListPendingRouteStates(ctx) + if err != nil { + s.logger.Warn("failed to restore dynamic policy recovery deadlines", zap.Error(err)) + return + } + for _, state := range states { + s.scheduleDynamicPolicyRecovery(state.SessionID, state.Generation, state.PolicyStateJSON) + } +} + +func (s *Service) stopDynamicPolicyRecovery() { + s.dynamicRecoveryMu.Lock() + defer s.dynamicRecoveryMu.Unlock() + if s.dynamicRecoveryCancel != nil { + s.dynamicRecoveryCancel() + } + for sessionID, timer := range s.dynamicRecoveryTimers { + timer.Stop() + delete(s.dynamicRecoveryTimers, sessionID) + } + s.dynamicRecoveryCtx = nil + s.dynamicRecoveryCancel = nil +} + +func (s *Service) scheduleDynamicPolicyRecovery(sessionID string, generation int64, rawPolicyState string) { + if sessionID == "" || generation <= 0 { + return + } + var policyState dynamicruntime.PolicyState + if err := json.Unmarshal([]byte(rawPolicyState), &policyState); err != nil || policyState.Deadline == nil { + return + } + deadline := policyState.Deadline.UTC() + s.dynamicRecoveryMu.Lock() + ctx := s.dynamicRecoveryCtx + if ctx == nil { + s.dynamicRecoveryMu.Unlock() + return + } + if previous := s.dynamicRecoveryTimers[sessionID]; previous != nil { + previous.Stop() + } + delay := time.Until(deadline) + if delay < 0 { + delay = 0 + } + s.dynamicRecoveryTimers[sessionID] = time.AfterFunc(delay, func() { + s.runDynamicPolicyRecovery(ctx, sessionID, generation) + }) + s.dynamicRecoveryMu.Unlock() +} + +func (s *Service) runDynamicPolicyRecovery(ctx context.Context, sessionID string, generation int64) { + s.removeDynamicPolicyRecoveryTimer(sessionID) + if !s.dynamicPolicyRecoveryActive(ctx) { + return + } + loader, ok := s.repo.(dynamicRouteStateLoader) + if !ok { + return + } + state, ok := loadDueDynamicPolicyState(ctx, loader, sessionID, generation) + if !ok { + return + } + if s.rescheduleEarlyDynamicPolicyRecovery(sessionID, generation, state.PolicyStateJSON) { + return + } + resolved, err := s.profileExecutionResolver.ResumePendingRoute(ctx, sessionID, generation) + if s.handleDynamicPolicyResumeError(ctx, loader, sessionID, generation, err) { + return + } + s.persistDynamicPolicyRecovery(ctx, sessionID, generation, resolved) +} + +func (s *Service) removeDynamicPolicyRecoveryTimer(sessionID string) { + s.dynamicRecoveryMu.Lock() + delete(s.dynamicRecoveryTimers, sessionID) + s.dynamicRecoveryMu.Unlock() +} + +func (s *Service) dynamicPolicyRecoveryActive(ctx context.Context) bool { + return ctx != nil && ctx.Err() == nil && s.profileExecutionResolver != nil +} + +func loadDueDynamicPolicyState( + ctx context.Context, + loader dynamicRouteStateLoader, + sessionID string, + generation int64, +) (*dynamicruntime.RouteState, bool) { + state, err := loader.LoadRouteState(ctx, sessionID) + if err != nil || state == nil || state.Generation != generation { + return nil, false + } + if state.Status != string(routingpolicy.DecisionRetry) && state.Status != string(routingpolicy.DecisionWaitForReset) { + return nil, false + } + return state, true +} + +func (s *Service) rescheduleEarlyDynamicPolicyRecovery(sessionID string, generation int64, rawPolicyState string) bool { + deadline := dynamicPolicyDeadline(rawPolicyState) + if deadline == nil || !time.Now().UTC().Before(*deadline) { + return false + } + s.scheduleDynamicPolicyRecovery(sessionID, generation, rawPolicyState) + return true +} + +func (s *Service) handleDynamicPolicyResumeError( + ctx context.Context, + loader dynamicRouteStateLoader, + sessionID string, + generation int64, + err error, +) bool { + if err == nil { + return false + } + if errors.Is(err, dynamicruntime.ErrRecoveryNotDue) { + if refreshed, loadErr := loader.LoadRouteState(ctx, sessionID); loadErr == nil && refreshed != nil { + s.scheduleDynamicPolicyRecovery(sessionID, generation, refreshed.PolicyStateJSON) + } + } + if !errors.Is(err, dynamicruntime.ErrStaleGeneration) && !errors.Is(err, dynamicruntime.ErrRouteStateNotFound) { + s.logger.Warn("dynamic policy recovery could not resume route", + zap.String("session_id", sessionID), zap.Error(err)) + } + return true +} + +func (s *Service) persistDynamicPolicyRecovery( + ctx context.Context, + sessionID string, + generation int64, + resolved agentruntime.ProfileExecution, +) { + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil || session == nil || session.RouteGeneration != generation || session.ExecutionProfileID != resolved.ExecutionProfileID { + return + } + applyDynamicRouteDecisionProjection(session, resolved.Decision) + session.RouteState = resolved.Decision.Status + session.State = models.TaskSessionStateCreated + session.DownstreamACPSessionID = "" + if err := s.repo.UpdateTaskSession(ctx, session); err != nil { + s.logger.Warn("failed to persist dynamic policy retry state", + zap.String("session_id", sessionID), zap.Error(err)) + return + } + if err := s.LaunchDynamicRouteAction(ctx, sessionID); err != nil { + s.markDynamicPolicyRecoveryActionRequired(ctx, sessionID, err) + } +} + +func dynamicPolicyDeadline(rawPolicyState string) *time.Time { + var policyState dynamicruntime.PolicyState + if err := json.Unmarshal([]byte(rawPolicyState), &policyState); err != nil || policyState.Deadline == nil { + return nil + } + deadline := policyState.Deadline.UTC() + return &deadline +} + +func (s *Service) markDynamicPolicyRecoveryActionRequired(ctx context.Context, sessionID string, launchErr error) { + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil || session == nil { + return + } + session.RouteState = "action_required" + session.RouteReason = "route_action_launch_failed" + session.State = models.TaskSessionStateWaitingForInput + session.ErrorMessage = launchErr.Error() + session.DownstreamACPSessionID = "" + if err := s.repo.UpdateTaskSession(ctx, session); err != nil { + s.logger.Warn("failed to persist dynamic policy launch failure", + zap.String("session_id", sessionID), zap.Error(err)) + } +} diff --git a/apps/backend/internal/orchestrator/event_handlers_agent.go b/apps/backend/internal/orchestrator/event_handlers_agent.go index 3dcf26f15b..62558f5300 100644 --- a/apps/backend/internal/orchestrator/event_handlers_agent.go +++ b/apps/backend/internal/orchestrator/event_handlers_agent.go @@ -1330,6 +1330,7 @@ func (s *Service) handleAgentFailed(ctx context.Context, data watcher.AgentEvent } func (s *Service) handleAgentFailedLocked(ctx context.Context, data watcher.AgentEventData) { + data = s.withDynamicAttemptEvidence(data) s.logger.Warn("handling agent failed", zap.String("task_id", data.TaskID), zap.String("session_id", data.SessionID), @@ -1357,6 +1358,9 @@ func (s *Service) handleAgentFailedLocked(ctx context.Context, data watcher.Agen if data.SessionID != "" && s.handleTransientFailure(ctx, data) { return } + if data.SessionID != "" && s.routeDynamicAgentFailure(ctx, data, classifyKanbanFailure(data)) { + return + } // All paths below are terminal for this execution (resume recovery included). // A transient retry returned above and retains activity until its execution diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go index 425bc4b8d8..e9af0d8f66 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go @@ -15,6 +15,7 @@ import ( "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/orchestrator/sessionstate" + "github.com/kandev/kandev/internal/orchestrator/watcher" "github.com/kandev/kandev/internal/task/models" taskservice "github.com/kandev/kandev/internal/task/service" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -91,6 +92,20 @@ func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle } return } + switch eventType { + case "message_streaming", "thinking_streaming", agentEventComplete: + s.observeDynamicAttempt( + payload.SessionID, + payload.ExecutionID, + strings.TrimSpace(payload.Data.Text) != "", + false, + ) + case agentEventToolCall, agentEventToolUpdate: + s.observeDynamicAttempt(payload.SessionID, payload.ExecutionID, false, true) + } + if eventType == agentEventComplete { + defer s.clearDynamicAttemptEvidence(payload.SessionID, payload.ExecutionID) + } if !terminalCompleteStream { // Any live agent stream activity means the agent resumed after clarification. @@ -287,6 +302,23 @@ func (s *Service) completeTurnForStreamEvent( func (s *Service) handleAgentErrorEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { taskID := payload.TaskID sessionID := payload.SessionID + if sessionID != "" { + failure := watcher.AgentEventData{ + TaskID: taskID, + SessionID: sessionID, + AgentExecutionID: payload.ExecutionID, + AgentID: payload.AgentID, + ErrorMessage: payload.Data.Error, + ProviderError: payload.Data.ProviderError, + } + if failure.ErrorMessage == "" { + failure.ErrorMessage = payload.Data.Text + } + failure = s.withDynamicAttemptEvidence(failure) + if s.routeDynamicAgentFailure(ctx, failure, classifyKanbanFailure(failure)) { + return + } + } if sessionID != "" && s.messageCreator != nil { errorMsg := payload.Data.Error if errorMsg == "" { @@ -1340,15 +1372,26 @@ func (s *Service) publishTaskSessionStateChanged( foregroundActivity = string(activity) } eventData := map[string]interface{}{ - metaKeyTaskID: taskID, - metaKeySessionID: sessionID, - "old_state": string(oldState), - metaKeyNewState: string(nextState), - "error_message": errorMessage, - metaKeyAgentProfileID: agentProfileID, - "agent_profile_snapshot": session.AgentProfileSnapshot, - "is_passthrough": session.IsPassthrough, - "is_primary": session.IsPrimary, + metaKeyTaskID: taskID, + metaKeySessionID: sessionID, + "old_state": string(oldState), + metaKeyNewState: string(nextState), + "error_message": errorMessage, + metaKeyAgentProfileID: agentProfileID, + "agent_profile_snapshot": session.AgentProfileSnapshot, + "execution_profile_id": session.ExecutionProfileID, + "route_generation": session.RouteGeneration, + "route_state": session.RouteState, + "route_reason": session.RouteReason, + "route_error_code": session.RouteErrorCode, + "route_error_class": session.RouteErrorClass, + "route_catalogue_version": session.RouteCatalogueVersion, + "route_retry_ordinal": session.RouteRetryOrdinal, + "route_deadline": session.RouteDeadline, + "route_pending_outcome": session.RoutePendingOutcome, + "downstream_acp_session_id": session.DownstreamACPSessionID, + "is_passthrough": session.IsPassthrough, + "is_primary": session.IsPrimary, // Carry activity only while the durable session is RUNNING. Every other // state gets an explicit null so partial client-store merges clear a // previously-live busy signal during settlement or teardown. diff --git a/apps/backend/internal/orchestrator/event_handlers_transient.go b/apps/backend/internal/orchestrator/event_handlers_transient.go index 32550976f6..4f63ee6f02 100644 --- a/apps/backend/internal/orchestrator/event_handlers_transient.go +++ b/apps/backend/internal/orchestrator/event_handlers_transient.go @@ -125,6 +125,13 @@ func (s *Service) rememberTurnPrompt(sessionID, text, model string, planMode boo // handleRecoverableFailure); false for non-transient errors, office tasks, // or an exhausted retry budget. func (s *Service) handleTransientFailure(ctx context.Context, data watcher.AgentEventData) bool { + data = s.withDynamicAttemptEvidence(data) + // Dynamic profiles own both error classes and their retry/reset policy. The + // legacy Kanban retry ladder must not consume a configured dynamic retry + // budget before the shared evaluator sees the failure. + if data.DynamicRouteAttempt { + return false + } classified := classifyKanbanFailure(data) if data.SessionID == "" || routingerr.Decide(routingerr.ContextKanban, classified, time.Now().UTC()) != routingerr.DecisionShortRetry { return false @@ -388,8 +395,22 @@ func classifyKanbanFailure(data watcher.AgentEventData) *routingerr.Error { } resetHint = providerError.ResetAt } + phase := routingerr.PhasePromptSend + if data.DynamicRouteAttempt { + switch { + case data.EffectObserved: + phase = routingerr.PhaseToolExecution + case data.OutputObserved: + phase = routingerr.PhaseStreaming + case !data.EvidenceKnown: + // Unknown attempt state is deliberately classified outside the + // pre-result phases. The dynamic route gate also requires explicit + // evidence, so this remains a defensive second fence. + phase = routingerr.PhaseStreaming + } + } return routingerr.Classify(routingerr.Input{ - Phase: routingerr.PhasePromptSend, + Phase: phase, ProviderID: providerID, ResetHint: resetHint, Stderr: message, diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 4e83c82e0d..21571b6cb0 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -491,6 +491,7 @@ type LaunchOptions struct { ExecutorID string TurnID string Prompt string + PriorACPSession string // ACP session ID to resume for the same concrete profile WorkflowStepID string StartAgent bool McpMode string // MCP tool mode: empty task default, McpModeTaskTitlePending, McpModeConfig, or McpModeOffice diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 0ebf7b39a4..2a59a3284c 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -1082,7 +1082,13 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses // - kanban / quick-chat re-launches that hit the full path. // Unlike ResumeSession we do NOT clear req.TaskDescription — wakeups // deliver the new comment / event as the prompt. - if startAgent { + if startAgent && opts.PriorACPSession != "" { + req.ACPSessionID = opts.PriorACPSession + e.logger.Info("resuming ACP session via dynamic route state", + zap.String("task_id", task.ID), + zap.String("session_id", sessionID), + zap.String("acp_session_id", opts.PriorACPSession)) + } else if startAgent { if token := resumeTokenForExecutionProfile(running, agentProfileID); token != "" { req.ACPSessionID = token e.logger.Info("resuming ACP session via stored resume token", diff --git a/apps/backend/internal/orchestrator/handlers/handlers.go b/apps/backend/internal/orchestrator/handlers/handlers.go index 8d0a8bd2a9..31e85a1314 100644 --- a/apps/backend/internal/orchestrator/handlers/handlers.go +++ b/apps/backend/internal/orchestrator/handlers/handlers.go @@ -45,10 +45,55 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionSessionSetPrimary, h.wsSetPrimarySession) d.RegisterFunc(ws.ActionSessionSetPlanMode, h.wsSetPlanMode) d.RegisterFunc(ws.ActionSessionRename, h.wsRenameSession) + d.RegisterFunc(ws.ActionSessionRouteAction, h.wsRouteAction) d.RegisterFunc(ws.ActionGitHubCheckSessionPR, h.wsCheckSessionPR) d.RegisterFunc(ws.ActionGitLabCheckSessionMR, h.wsCheckSessionMR) } +type wsRouteActionRequest struct { + SessionID string `json:"session_id"` + Action string `json:"action"` + ExpectedGeneration int64 `json:"expected_generation"` +} + +func (h *Handlers) wsRouteAction(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsRouteActionRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + } + if req.Action != string(orchestrator.RouteActionRetry) && + req.Action != string(orchestrator.RouteActionTryNext) && + req.Action != string(orchestrator.RouteActionSkip) && + req.Action != string(orchestrator.RouteActionCancelWait) && + req.Action != string(orchestrator.RouteActionStop) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "unsupported route action", nil) + } + result, err := h.service.ApplyRouteAction(ctx, orchestrator.RouteActionRequest{ + SessionID: req.SessionID, Action: orchestrator.RouteAction(req.Action), + ExpectedGeneration: req.ExpectedGeneration, + }) + if err != nil { + var conflict *orchestrator.RouteActionConflictError + if errors.As(err, &conflict) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeConflict, err.Error(), map[string]interface{}{ + "route": conflict.Result, + }) + } + if errors.Is(err, taskrepo.ErrTaskNotFound) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "session not found", nil) + } + if errors.Is(err, orchestrator.ErrRouteActionActiveTurn) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeConflict, err.Error(), nil) + } + h.logger.Warn("route action failed", zap.String("session_id", req.SessionID), zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "route action failed", nil) + } + return ws.NewResponse(msg.ID, msg.Action, result) +} + // WS handlers func (h *Handlers) wsGetStatus(ctx context.Context, msg *ws.Message) (*ws.Message, error) { diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 01897a58af..6c48114f7a 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -22,6 +22,7 @@ import ( "go.uber.org/zap" + agentruntime "github.com/kandev/kandev/internal/agent/runtime" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events" @@ -47,6 +48,7 @@ func isNoActiveTurnError(err error) bool { var ( ErrServiceAlreadyRunning = errors.New("service is already running") ErrServiceNotRunning = errors.New("service is not running") + ErrRouteActionActiveTurn = errors.New("route actions require a settled turn") ) // ServiceConfig holds orchestrator service configuration @@ -323,6 +325,8 @@ type sessionExecutorStore interface { // Messages — used by resume to backfill the initial user prompt when a // prior launch failed before recordInitialMessage ran. ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) + // Session history + plan used to build a provider-neutral continuation. + GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) // Pending clarification rows — durable guard for on_turn_complete while the user is answering. FindActiveClarificationMessagesBySessionID(ctx context.Context, sessionID string) ([]*models.Message, error) // Workspace @@ -507,6 +511,24 @@ type Service struct { // session.ensure). Nil = unscoped. taskAccessCheck func(ctx context.Context, taskID string) error + // routeActionHandler is owned by the dynamic conductor composition. The + // orchestrator only validates/authorizes the request and returns the + // authoritative route snapshot; concrete and dynamic callers share this + // seam. + routeActionHandler func(context.Context, RouteActionRequest) (*RouteActionResult, error) + + // profileExecutionResolver is the shared logical-to-concrete profile + // boundary used by task and workflow launch paths. + profileExecutionResolver *agentruntime.ProfileExecutionResolver + + // dynamicRecovery owns durable reset/retry deadlines. Only pending states + // are scheduled after restart; a retrying state means dispatch may have + // crossed the process boundary and remains manual until reconciled. + dynamicRecoveryMu sync.Mutex + dynamicRecoveryCtx context.Context + dynamicRecoveryCancel context.CancelFunc + dynamicRecoveryTimers map[string]*time.Timer + // titleBranchRuntime performs the lifecycle-owned Git branch rename after // an agent resolves a prompt-first task title. It is optional for tests and // installations that do not configure an agent runtime. @@ -929,6 +951,12 @@ type Service struct { // context. key: sessionID, value: capturedPrompt. Replaced every turn. lastTurnPrompt sync.Map + // dynamicAttemptEvidence is keyed by logical session. A dynamic attempt is + // replaced at every concrete launch, and its execution ID fences late + // stream/lifecycle events from a predecessor. Fallback requires an explicit + // no-output/no-effect result from this map. + dynamicAttemptEvidence sync.Map + // Service state mu sync.RWMutex running bool @@ -944,6 +972,103 @@ type Service struct { sendNowWorkers sync.WaitGroup } +type RouteAction string + +const ( + RouteActionRetry RouteAction = "retry" + RouteActionTryNext RouteAction = "try_next" + RouteActionSkip RouteAction = "skip" + RouteActionCancelWait RouteAction = "cancel_wait" + RouteActionStop RouteAction = "stop" +) + +type RouteActionRequest struct { + SessionID string + Action RouteAction + ExpectedGeneration int64 +} + +type RouteActionResult struct { + SessionID string `json:"session_id"` + LogicalProfileID string `json:"logical_profile_id"` + ExecutionProfileID string `json:"execution_profile_id,omitempty"` + RouteGeneration int64 `json:"route_generation"` + ProfileVersion int64 `json:"profile_version"` + State string `json:"state"` + Reason string `json:"reason,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + ErrorClass string `json:"error_class,omitempty"` + CatalogueVersion string `json:"catalogue_version,omitempty"` + RetryOrdinal int64 `json:"retry_ordinal,omitempty"` + Deadline *time.Time `json:"deadline,omitempty"` + PendingOutcome string `json:"pending_outcome,omitempty"` +} + +// RouteActionConflictError carries the authoritative route snapshot when a +// client submits an old generation. Callers can render the returned snapshot +// and retry with its generation without issuing a second read request. +type RouteActionConflictError struct { + Result *RouteActionResult + Err error +} + +func (e *RouteActionConflictError) Error() string { + if e == nil || e.Err == nil { + return "route action conflict" + } + return e.Err.Error() +} + +func (e *RouteActionConflictError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +func (s *Service) SetRouteActionHandler(handler func(context.Context, RouteActionRequest) (*RouteActionResult, error)) { + s.routeActionHandler = handler +} + +// SetProfileExecutionResolver wires the shared profile-kind boundary into +// task and workflow launches. +func (s *Service) SetProfileExecutionResolver(resolver *agentruntime.ProfileExecutionResolver) { + s.profileExecutionResolver = resolver +} + +func (s *Service) ApplyRouteAction(ctx context.Context, request RouteActionRequest) (*RouteActionResult, error) { + if request.SessionID == "" { + return nil, errors.New("session_id is required") + } + if request.Action != RouteActionRetry && request.Action != RouteActionTryNext && + request.Action != RouteActionSkip && request.Action != RouteActionCancelWait && + request.Action != RouteActionStop { + return nil, fmt.Errorf("unsupported route action %q", request.Action) + } + if s.routeActionHandler == nil { + return nil, errors.New("dynamic route actions are not configured") + } + if err := s.authorizeSession(ctx, request.SessionID); err != nil { + return nil, err + } + lock, release := s.acquireCancelInFlightGuard(request.SessionID) + lock.Lock() + defer func() { + lock.Unlock() + release() + }() + if s.turnService != nil { + activeTurn, err := s.turnService.GetActiveTurn(ctx, request.SessionID) + if err != nil && !isNoActiveTurnError(err) { + return nil, fmt.Errorf("check active turn for route action: %w", err) + } + if activeTurn != nil { + return nil, ErrRouteActionActiveTurn + } + } + return s.routeActionHandler(ctx, request) +} + // Status contains orchestrator status information type Status struct { Running bool `json:"running"` @@ -1027,6 +1152,7 @@ func NewService( taskLaunchRecoveryRepo: taskLaunchRecoveryRepo, clarificationWatchdogTimeout: 15 * time.Second, gitSnapshotCache: newGitSnapshotCache(), + dynamicRecoveryTimers: make(map[string]*time.Timer), reservedPromptCallbacks: newReservedPromptCallbackOwner(), sendNowCtx: sendNowCtx, sendNowCancel: sendNowCancel, @@ -2186,6 +2312,10 @@ func (s *Service) Start(ctx context.Context) error { // Reconcile queued tasks when WIP limits or feeder settings change. s.subscribeWorkflowQueueEvents() + // Restore durable dynamic policy waits after the route and lifecycle + // services are ready. Only un-dispatched pending states are scheduled. + s.startDynamicPolicyRecovery(ctx) + // Start the idle-session reaper last. It depends on s.repo // (already wired), s.agentManager (already wired), and the // turnService-cleared reconciler so a turnService == nil error @@ -2209,6 +2339,7 @@ func (s *Service) Stop() error { s.mu.Unlock() s.logger.Info("stopping orchestrator service") + s.stopDynamicPolicyRecovery() // Stop components in reverse order var errs []error diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 3b9c9c0278..6e1c9b2c2a 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -18,6 +18,7 @@ import ( agentruntime "github.com/kandev/kandev/internal/agent/runtime" client "github.com/kandev/kandev/internal/agent/runtime/agentctl" + dynamicruntime "github.com/kandev/kandev/internal/agent/runtime/dynamic" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" "github.com/kandev/kandev/internal/agent/runtime/routingerr" "github.com/kandev/kandev/internal/common/constants" @@ -86,6 +87,14 @@ type primarySessionTaskStateUpdater interface { ) (bool, error) } +// dynamicRouteStateLoader exposes the durable route row without coupling the +// orchestrator to the concrete task repository. The route row is authoritative +// when a generation claim committed before task-session attribution could be +// written; launch reconciliation repairs the session from that row. +type dynamicRouteStateLoader interface { + LoadRouteState(context.Context, string) (*dynamicruntime.RouteState, error) +} + // ErrSessionNotPromptable is returned when a session cannot accept a prompt // because of its lifecycle state (STARTING, CREATED, FAILED, CANCELLED). // Distinct from ErrAgentPromptInProgress, which is RUNNING-only — confusing @@ -294,6 +303,11 @@ func (s *Service) PrepareTaskSession(ctx context.Context, taskID string, agentPr workflowStepID = dbTask.WorkflowStepID } } + if s.profileExecutionResolver != nil { + if err := s.profileExecutionResolver.ValidateProfile(ctx, agentProfileID); err != nil { + return "", err + } + } // Create session entry in database. Office tasks route through // EnsureSessionForAgent so runs + advanced-mode reuse one row. @@ -321,7 +335,37 @@ func (s *Service) PrepareTaskSession(ctx context.Context, taskID string, agentPr // and shows preparation progress via executor.prepare.progress WS events. go func() { bgCtx := context.Background() - prepExec, launchErr := s.executor.LaunchPreparedSession(bgCtx, task, sessionID, executor.LaunchOptions{AgentProfileID: agentProfileID, ExecutorID: executorID, WorkflowStepID: workflowStepID}) + launchProfileID := agentProfileID + if s.profileExecutionResolver != nil { + launchSession, loadErr := s.repo.GetTaskSession(bgCtx, sessionID) + if loadErr != nil { + s.logger.Warn("failed to reload prepared session for dynamic route resolution", + zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.Error(loadErr)) + _ = s.handleSessionLaunchFailure(bgCtx, taskID, sessionID, loadErr) + return + } + resolved, routeClaimed, resolveErr := s.resolveExecutionForLaunchSession(bgCtx, launchSession) + if resolveErr != nil { + s.recordDynamicRouteResolutionFailure(bgCtx, launchSession, resolveErr) + s.logger.Warn("failed to resolve dynamic route for prepared session", + zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.Error(resolveErr)) + _ = s.handleSessionLaunchFailure(bgCtx, taskID, sessionID, resolveErr, launchSession) + return + } + if resolved.ExecutionProfileID != "" { + launchProfileID = resolved.ExecutionProfileID + if routeClaimed { + applyResolvedExecution(launchSession, resolved) + if updateErr := s.repo.UpdateTaskSession(bgCtx, launchSession); updateErr != nil { + s.logger.Warn("failed to persist dynamic route for prepared session", + zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.Error(updateErr)) + _ = s.handleSessionLaunchFailure(bgCtx, taskID, sessionID, updateErr, launchSession) + return + } + } + } + } + prepExec, launchErr := s.executor.LaunchPreparedSession(bgCtx, task, sessionID, executor.LaunchOptions{AgentProfileID: launchProfileID, ExecutorID: executorID, WorkflowStepID: workflowStepID}) if launchErr != nil { // LaunchAgent failures persist FAILED in the executor. Earlier // workspace failures return here and are recorded through the same @@ -425,6 +469,11 @@ func (s *Service) StartCreatedSession( if effectiveProfileID == "" { return nil, fmt.Errorf("agent_profile_id is required") } + if s.profileExecutionResolver != nil { + if err := s.profileExecutionResolver.ValidateProfile(ctx, effectiveProfileID); err != nil { + return nil, err + } + } // If the workflow step overrode the profile, update the session record in DB // so the frontend tab displays the correct agent (it reads session.agent_profile_id). @@ -507,6 +556,10 @@ func (s *Service) StartCreatedSession( effectiveProfileID = activeSession.AgentProfileID } + if effectiveProfileID, err = s.resolveDynamicLaunchExecution(ctx, session, effectiveProfileID, true); err != nil { + return nil, err + } + // Apply workflow step prompt wrapping and plan mode injection. // Called unconditionally so workflow-step prompt composition (prefix/suffix) // applies even when plan mode is not requested. @@ -562,7 +615,7 @@ func (s *Service) StartCreatedSession( mcpMode = executor.McpModeOffice } initialTurnID, initialTurnCreated := s.startTurnForSessionWithOwnership(ctx, sessionID) - execution, err := s.executor.LaunchPreparedSession(ctx, task, sessionID, executor.LaunchOptions{AgentProfileID: effectiveProfileID, ExecutorID: executorID, Prompt: effectivePrompt, StartAgent: true, McpMode: mcpMode, Attachments: attachments, TurnID: initialTurnID}) + execution, err := s.launchPreparedSessionWithDynamicFallback(ctx, task, sessionID, executor.LaunchOptions{AgentProfileID: effectiveProfileID, ExecutorID: executorID, Prompt: effectivePrompt, StartAgent: true, McpMode: mcpMode, Attachments: attachments, TurnID: initialTurnID}) if err != nil { // The executor persists LaunchAgent failures. Cover earlier prepared-session // failures here; the session-level claim makes either completion order safe. @@ -827,6 +880,15 @@ func (s *Service) StartTaskWithRoute( func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID string, executorID string, executorProfileID string, priority string, prompt string, workflowStepID string, planMode, autoStart bool, attachments []v1.MessageAttachment, opts startTaskOptions) (*executor.TaskExecution, error) { // One GetWorkflowMeta read shared by profile resolution and prompt build. ctx = withWorkflowMetaCache(ctx) + // Fail before task-state or session mutations when the selected logical + // profile belongs to a disabled dynamic family. The workflow step may later + // override the caller profile, so repeat the check after that resolution. + if s.profileExecutionResolver != nil { + preflightProfileID := s.resolveEffectiveAgentProfile(ctx, taskID, workflowStepID, agentProfileID) + if err := s.profileExecutionResolver.ValidateProfile(ctx, preflightProfileID); err != nil { + return nil, err + } + } env, route := opts.Env, opts.Route s.logger.Debug("manually starting task", @@ -919,6 +981,11 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s } else { agentProfileID = s.resolveEffectiveAgentProfile(ctx, taskID, workflowStepID, agentProfileID) } + if s.profileExecutionResolver != nil { + if err := s.profileExecutionResolver.ValidateProfile(ctx, agentProfileID); err != nil { + return nil, err + } + } overrideApplied := agentProfileID != callerProfileID if route != nil && route.ExecutionProfileID != "" { agentProfileID = route.ExecutionProfileID @@ -1002,6 +1069,16 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s return nil, fmt.Errorf("failed to reload launch session: %w", err) } + if route == nil { + if agentProfileID, err = s.resolveDynamicLaunchExecution(ctx, launchSession, agentProfileID, false); err != nil { + return nil, err + } + } + // Dynamic resolution updates the session snapshot after the initial + // passthrough check. Use the concrete candidate's snapshot for prompt + // wrapping and launch behavior. + isPassthrough = launchSession.IsPassthrough + effectivePrompt, planModeActive, promptReferenceContext := s.applyWorkflowAndPlanMode( ctx, effectivePrompt, task.ID, sessionID, workflowStepID, planMode, task.IsEphemeral, isPassthrough, @@ -1066,7 +1143,7 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s // re-drive this first turn — initial launches bypass PromptTask. s.rememberTurnPrompt(sessionID, prompt, "", planMode, attachments) - execution, err := s.executor.LaunchPreparedSession(ctx, task, sessionID, executor.LaunchOptions{ + execution, err := s.launchPreparedSessionWithDynamicFallback(ctx, task, sessionID, executor.LaunchOptions{ AgentProfileID: agentProfileID, OfficeAgentProfileID: officeAgentProfileID, ExecutorID: executorID, @@ -1314,6 +1391,159 @@ func (s *Service) resolveIsPassthroughForLaunch(ctx context.Context, sessionID s return launchSession.IsPassthrough } +// resolveExecutionForLaunchSession keeps the logical profile on the session +// while selecting a concrete profile only once for a new dynamic route. A +// persisted route is authoritative during resume and must not advance merely +// because a caller is launching the same logical session again. +func (s *Service) resolveExecutionForLaunchSession( + ctx context.Context, + session *models.TaskSession, +) (agentruntime.ProfileExecution, bool, error) { + if session == nil { + return agentruntime.ProfileExecution{}, false, errors.New("launch session is required for profile resolution") + } + if resolved, routeClaimed, handled, err := s.resolveDurableExecutionForLaunch(ctx, session); handled { + return resolved, routeClaimed, err + } + if session.RouteGeneration > 0 && session.ExecutionProfileID != "" { + resolved, err := s.profileExecutionResolver.ResolveExisting( + ctx, session.ID, session.AgentProfileID, session.ExecutionProfileID, + session.RouteGeneration, 0, session.RouteReason, + ) + return resolved, false, err + } + resolved, err := s.profileExecutionResolver.Resolve( + ctx, session.ID, session.AgentProfileID, session.RouteGeneration, "", + ) + if err != nil { + return agentruntime.ProfileExecution{}, false, err + } + return resolved, resolved.Generation > 0, nil +} + +func (s *Service) resolveDurableExecutionForLaunch( + ctx context.Context, + session *models.TaskSession, +) (agentruntime.ProfileExecution, bool, bool, error) { + loader, ok := s.repo.(dynamicRouteStateLoader) + if !ok { + return agentruntime.ProfileExecution{}, false, false, nil + } + state, err := loader.LoadRouteState(ctx, session.ID) + if err != nil { + return agentruntime.ProfileExecution{}, false, true, fmt.Errorf("load durable dynamic route state: %w", err) + } + if state == nil || state.LogicalProfileID != session.AgentProfileID || state.Generation <= 0 { + return agentruntime.ProfileExecution{}, false, false, nil + } + if state.ExecutionProfileID == "" { + if state.Status != dynamicRouteStatusWaiting { + return agentruntime.ProfileExecution{}, false, true, &dynamicruntime.NoEligibleCandidateError{ + SessionID: session.ID, LogicalProfile: session.AgentProfileID, + Generation: state.Generation, + } + } + resolved, resolveErr := s.profileExecutionResolver.Resolve( + ctx, session.ID, session.AgentProfileID, state.Generation, "", + ) + return resolved, resolved.Generation > 0, true, resolveErr + } + resolved, err := s.profileExecutionResolver.ResolveExisting( + ctx, session.ID, session.AgentProfileID, state.ExecutionProfileID, + state.Generation, state.ProfileVersion, "durable_route_state", + ) + return resolved, true, true, err +} + +func (s *Service) resolveDynamicLaunchExecution( + ctx context.Context, + session *models.TaskSession, + profileID string, + validate bool, +) (string, error) { + if s.profileExecutionResolver == nil { + return profileID, nil + } + if validate { + if err := s.profileExecutionResolver.ValidateProfile(ctx, session.AgentProfileID); err != nil { + return "", err + } + } + resolved, routeClaimed, err := s.resolveExecutionForLaunchSession(ctx, session) + if err != nil { + s.recordDynamicRouteResolutionFailure(ctx, session, err) + return "", err + } + if !routeClaimed { + if resolved.ExecutionProfileID == "" { + return profileID, nil + } + return resolved.ExecutionProfileID, nil + } + applyResolvedExecution(session, resolved) + if err := s.repo.UpdateTaskSession(ctx, session); err != nil { + return "", fmt.Errorf("persist dynamic route attribution: %w", err) + } + return resolved.ExecutionProfileID, nil +} + +func applyResolvedExecution(session *models.TaskSession, resolved agentruntime.ProfileExecution) { + if session == nil { + return + } + previousExecutionProfileID := session.ExecutionProfileID + session.ExecutionProfileID = resolved.ExecutionProfileID + session.RouteGeneration = resolved.Generation + session.RouteState = "starting" + session.RouteReason = resolved.Decision.Reason + if session.RouteReason == "" { + session.RouteReason = "candidate_order" + } + applyDynamicRouteDecisionProjection(session, resolved.Decision) + // A new concrete candidate never inherits a provider-native ACP identity. + // Reapplying the persisted route during restart must keep the identity so + // native conversation resume remains possible. + if previousExecutionProfileID != resolved.ExecutionProfileID { + session.DownstreamACPSessionID = "" + } + if resolved.Profile != nil { + session.AgentProfileSnapshot = map[string]interface{}{ + "id": resolved.Profile.ID, + "name": resolved.Profile.Name, + "agent_id": resolved.Profile.AgentID, + "model": resolved.Profile.Model, + "mode": resolved.Profile.Mode, + "config_options": resolved.Profile.ConfigOptions, + "auto_approve": resolved.Profile.AutoApprove, + "dangerously_skip_permissions": resolved.Profile.DangerouslySkipPermissions, + "cli_passthrough": resolved.Profile.CLIPassthrough, + } + session.IsPassthrough = resolved.Profile.CLIPassthrough + } +} + +func (s *Service) recordDynamicRouteResolutionFailure( + ctx context.Context, + session *models.TaskSession, + err error, +) { + if session == nil || err == nil { + return + } + var noCandidate *dynamicruntime.NoEligibleCandidateError + if !errors.As(err, &noCandidate) { + return + } + session.RouteGeneration = noCandidate.Generation + session.RouteState = dynamicRouteStatusWaiting + session.RouteReason = "no_eligible_candidate" + session.UpdatedAt = time.Now().UTC() + if updateErr := s.repo.UpdateTaskSession(ctx, session); updateErr != nil { + s.logger.Warn("failed to persist dynamic route waiting state", + zap.String("session_id", session.ID), zap.Error(updateErr)) + } +} + // createStartSession picks the right session-creation path for the task: // Office tasks with an assignee use the per-(task, agent) EnsureSessionForAgent // (so runs reuse one row across turns); kanban / quick-chat fall through to @@ -1684,6 +1914,9 @@ func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID strin if isOfficeTask { return nil, errOfficeTaskResumeRequiresScheduler } + if _, err := s.resolveDynamicLaunchExecution(ctx, session, session.AgentProfileID, true); err != nil { + return nil, err + } // Bury any open turns from the previous run before relaunching. Without // this, startTurnForSession adopts the orphan on the next prompt and the @@ -2239,7 +2472,7 @@ func (s *Service) startAgentOnPreparedWorkspace(ctx context.Context, sessionID s if _, err := s.ClaimTaskTitleSession(launchCtx, session.TaskID, sessionID); err != nil { return fmt.Errorf("failed to claim first-turn task title: %w", err) } - if _, err = s.executor.LaunchPreparedSession(launchCtx, task, sessionID, executor.LaunchOptions{ + if _, err = s.launchPreparedSessionWithDynamicFallback(launchCtx, task, sessionID, executor.LaunchOptions{ AgentProfileID: session.AgentProfileID, ExecutorID: session.ExecutorID, StartAgent: true, diff --git a/apps/backend/internal/orchestrator/turn_lifecycle_test.go b/apps/backend/internal/orchestrator/turn_lifecycle_test.go index 22cf8e6070..2be4f6d672 100644 --- a/apps/backend/internal/orchestrator/turn_lifecycle_test.go +++ b/apps/backend/internal/orchestrator/turn_lifecycle_test.go @@ -205,6 +205,56 @@ func newTurnLifecycleTestService(t *testing.T) (*Service, *sqliterepo.Repository return svc, repo } +func TestApplyRouteActionRejectsActiveTurn(t *testing.T) { + svc, _ := newTurnLifecycleTestService(t) + ctx := context.Background() + if _, err := svc.turnService.StartTurn(ctx, "session1"); err != nil { + t.Fatalf("start turn: %v", err) + } + svc.SetRouteActionHandler(func(context.Context, RouteActionRequest) (*RouteActionResult, error) { + t.Fatal("route action handler must not run while a turn is active") + return nil, nil + }) + + _, err := svc.ApplyRouteAction(ctx, RouteActionRequest{ + SessionID: "session1", + Action: RouteActionRetry, + }) + if !errors.Is(err, ErrRouteActionActiveTurn) { + t.Fatalf("ApplyRouteAction error = %v, want %v", err, ErrRouteActionActiveTurn) + } +} + +func TestApplyRouteActionRunsAfterTurnSettles(t *testing.T) { + svc, _ := newTurnLifecycleTestService(t) + ctx := context.Background() + turn, err := svc.turnService.StartTurn(ctx, "session1") + if err != nil { + t.Fatalf("start turn: %v", err) + } + if err := svc.turnService.CompleteTurn(ctx, turn.ID); err != nil { + t.Fatalf("complete turn: %v", err) + } + want := &RouteActionResult{SessionID: "session1", State: "starting"} + svc.SetRouteActionHandler(func(_ context.Context, request RouteActionRequest) (*RouteActionResult, error) { + if request.Action != RouteActionTryNext { + t.Fatalf("route action = %q, want %q", request.Action, RouteActionTryNext) + } + return want, nil + }) + + got, err := svc.ApplyRouteAction(ctx, RouteActionRequest{ + SessionID: "session1", + Action: RouteActionTryNext, + }) + if err != nil { + t.Fatalf("ApplyRouteAction: %v", err) + } + if got != want { + t.Fatalf("ApplyRouteAction result = %#v, want %#v", got, want) + } +} + func TestStartTurnDropsPromptAfterActiveTurnLookupFailure(t *testing.T) { svc, repo := newTurnLifecycleTestService(t) lookupErr := errors.New("active turn read failed") diff --git a/apps/backend/internal/orchestrator/watcher/watcher.go b/apps/backend/internal/orchestrator/watcher/watcher.go index 0abda79e5a..35663da317 100644 --- a/apps/backend/internal/orchestrator/watcher/watcher.go +++ b/apps/backend/internal/orchestrator/watcher/watcher.go @@ -42,6 +42,13 @@ type AgentEventData struct { FailureDetails string `json:"failure_details,omitempty"` ProviderError *streams.ProviderError `json:"provider_error,omitempty"` PromptGeneration uint64 `json:"prompt_generation,omitempty"` + // DynamicRouteAttempt marks failures and stream evidence that belong to a + // dynamic provider attempt. Fallback is fail-closed unless the evidence is + // explicitly known to contain no output or effects. + DynamicRouteAttempt bool `json:"dynamic_route_attempt,omitempty"` + EvidenceKnown bool `json:"evidence_known,omitempty"` + OutputObserved bool `json:"output_observed,omitempty"` + EffectObserved bool `json:"effect_observed,omitempty"` } // ACPSessionEventData contains data from ACP session events diff --git a/apps/backend/internal/profiles/profiles.yaml b/apps/backend/internal/profiles/profiles.yaml index f3fabd0d7c..e97e4fddf9 100644 --- a/apps/backend/internal/profiles/profiles.yaml +++ b/apps/backend/internal/profiles/profiles.yaml @@ -54,6 +54,13 @@ features: dev: "false" e2e: "false" + # Dynamic agent profiles and provider-error routing. Off in every shipped + # profile until the complete guarded implementation is ready. + KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING: + prod: "false" + dev: "false" + e2e: "false" + # High-risk Claude-only background prompt handoff experiment. Off in every # profile so it is exercised only by contributors who deliberately opt in. KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF: diff --git a/apps/backend/internal/profiles/profiles_test.go b/apps/backend/internal/profiles/profiles_test.go index db7071807d..8c90e29f7e 100644 --- a/apps/backend/internal/profiles/profiles_test.go +++ b/apps/backend/internal/profiles/profiles_test.go @@ -42,6 +42,9 @@ func TestApplyProfile_DefaultsToProd(t *testing.T) { if v := os.Getenv("KANDEV_FEATURES_AUTH"); v != "false" { t.Errorf("KANDEV_FEATURES_AUTH = %q after prod ApplyProfile; want %q", v, "false") } + if v := os.Getenv("KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING"); v != "false" { + t.Errorf("KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING = %q after prod ApplyProfile; want %q", v, "false") + } if v := os.Getenv("KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF"); v != "false" { t.Errorf( "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF = %q after prod ApplyProfile; want %q", diff --git a/apps/backend/internal/runtimeflags/registry.go b/apps/backend/internal/runtimeflags/registry.go index 0b93433890..8449de478f 100644 --- a/apps/backend/internal/runtimeflags/registry.go +++ b/apps/backend/internal/runtimeflags/registry.go @@ -67,6 +67,23 @@ var registrations = []runtimeFlagRegistration{ read: func(cfg *config.Config) bool { return cfg.Features.Auth }, apply: func(cfg *config.Config, value bool) { cfg.Features.Auth = value }, }, + { + definition: RuntimeFlagDefinition{ + Key: "features.dynamicAgentRouting", + EnvVar: "KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING", + Kind: KindFeature, + Label: "Dynamic agent routing", + Description: "Enables reusable dynamic agent profiles and provider-error routing across task, utility, and Office execution.", + Stability: StabilityExperimental, + RiskLevel: RiskHigh, + RiskDescription: "Dynamic routing can change the concrete provider used by a logical session and is still experimental. " + + "Enable it only on a controlled installation and review route recovery behavior before using it for unattended work.", + RestartRequired: true, + Mutable: true, + }, + read: func(cfg *config.Config) bool { return cfg.Features.DynamicAgentRouting }, + apply: func(cfg *config.Config, value bool) { cfg.Features.DynamicAgentRouting = value }, + }, { definition: RuntimeFlagDefinition{ Key: "features.claudeBackgroundPromptHandoff", diff --git a/apps/backend/internal/runtimeflags/registry_test.go b/apps/backend/internal/runtimeflags/registry_test.go index 97fcdfb365..7c6e6c09ee 100644 --- a/apps/backend/internal/runtimeflags/registry_test.go +++ b/apps/backend/internal/runtimeflags/registry_test.go @@ -36,6 +36,31 @@ func TestDefinitionsIncludeOfficeExperimentalMetadata(t *testing.T) { } } +func TestDefinitionsIncludeDynamicAgentRoutingMetadata(t *testing.T) { + def, ok := DefinitionByKey("features.dynamicAgentRouting") + if !ok { + t.Fatal("features.dynamicAgentRouting definition missing") + } + if def.EnvVar != "KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING" { + t.Fatalf("EnvVar = %q, want KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING", def.EnvVar) + } + if def.Stability != StabilityExperimental { + t.Fatalf("Stability = %q, want %q", def.Stability, StabilityExperimental) + } + if def.RiskLevel != RiskHigh { + t.Fatalf("RiskLevel = %q, want %q", def.RiskLevel, RiskHigh) + } + if def.RiskDescription == "" { + t.Fatal("RiskDescription empty") + } + if !def.RestartRequired { + t.Fatal("RestartRequired = false, want true") + } + if !def.Mutable { + t.Fatal("Mutable = false, want true") + } +} + // TestDefinitionsExcludePlugins pins the graduation of the plugin system out // of the feature-flag tier: plugins ship in the base product, so no toggle may // reappear in Settings > System > Feature Toggles. diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index fa9e8288ca..645f512eb6 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -1221,14 +1221,20 @@ func copyMetadata(m map[string]any) map[string]any { // A turn starts when a user sends a prompt and ends when the agent completes, // cancels, or errors. type Turn struct { - ID string `json:"id"` - TaskSessionID string `json:"session_id"` - TaskID string `json:"task_id"` - StartedAt time.Time `json:"started_at"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + TaskSessionID string `json:"session_id"` + TaskID string `json:"task_id"` + // ExecutionProfileID and RouteGeneration are immutable attribution for the + // concrete route that started this turn. They are kept alongside the + // existing metadata during the compatibility migration so old rows remain + // readable. + ExecutionProfileID string `json:"execution_profile_id,omitempty"` + RouteGeneration int64 `json:"route_generation,omitempty"` + StartedAt time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // ReviewStatus represents the review state of a TaskSession. The zero value @@ -1302,31 +1308,41 @@ type SessionBranchInfo struct { // TaskSession represents a persistent agent execution session for a task. // This replaces the in-memory TaskExecution tracking and survives backend restarts. type TaskSession struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - Name string `json:"name,omitempty"` // Optional user-supplied label shown on the session tab - AgentExecutionID string `json:"agent_execution_id"` // Docker container/agent execution - ContainerID string `json:"container_id"` // Docker container ID for cleanup - AgentProfileID string `json:"agent_profile_id"` // ID of the agent profile used - ExecutionProfileID string `json:"execution_profile_id"` // Concrete profile used for this execution - ExecutorID string `json:"executor_id"` - ExecutorProfileID string `json:"executor_profile_id"` - EnvironmentID string `json:"environment_id"` - RepositoryID string `json:"repository_id"` // Primary repository (for backward compatibility) - BaseBranch string `json:"base_branch"` // Primary base branch (for backward compatibility) - BaseCommitSHA string `json:"base_commit_sha"` // Git commit SHA at session start (for cumulative diff) - WorkspacePath string `json:"workspace_path"` // Effective task workspace root; legacy repo-less sessions may use the picked host folder - Worktrees []*TaskEnvironmentRepo `json:"-"` // Environment repository rows for this session's workspace - AgentProfileSnapshot map[string]interface{} `json:"agent_profile_snapshot,omitempty"` - ExecutorSnapshot map[string]interface{} `json:"executor_snapshot,omitempty"` - EnvironmentSnapshot map[string]interface{} `json:"environment_snapshot,omitempty"` - RepositorySnapshot map[string]interface{} `json:"repository_snapshot,omitempty"` - State TaskSessionState `json:"state"` - ErrorMessage string `json:"error_message,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - StartedAt time.Time `json:"started_at"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + TaskID string `json:"task_id"` + Name string `json:"name,omitempty"` // Optional user-supplied label shown on the session tab + AgentExecutionID string `json:"agent_execution_id"` // Docker container/agent execution + ContainerID string `json:"container_id"` // Docker container ID for cleanup + AgentProfileID string `json:"agent_profile_id"` // ID of the agent profile used + ExecutionProfileID string `json:"execution_profile_id"` // Concrete profile used for this execution + RouteGeneration int64 `json:"route_generation,omitempty"` + RouteState string `json:"route_state,omitempty"` + RouteReason string `json:"route_reason,omitempty"` + DownstreamACPSessionID string `json:"downstream_acp_session_id,omitempty"` + RouteErrorCode string `json:"route_error_code,omitempty"` + RouteErrorClass string `json:"route_error_class,omitempty"` + RouteCatalogueVersion string `json:"route_catalogue_version,omitempty"` + RouteRetryOrdinal int64 `json:"route_retry_ordinal,omitempty"` + RouteDeadline *time.Time `json:"route_deadline,omitempty"` + RoutePendingOutcome string `json:"route_pending_outcome,omitempty"` + ExecutorID string `json:"executor_id"` + ExecutorProfileID string `json:"executor_profile_id"` + EnvironmentID string `json:"environment_id"` + RepositoryID string `json:"repository_id"` // Primary repository (for backward compatibility) + BaseBranch string `json:"base_branch"` // Primary base branch (for backward compatibility) + BaseCommitSHA string `json:"base_commit_sha"` // Git commit SHA at session start (for cumulative diff) + WorkspacePath string `json:"workspace_path"` // Effective task workspace root; legacy repo-less sessions may use the picked host folder + Worktrees []*TaskEnvironmentRepo `json:"-"` // Environment repository rows for this session's workspace + AgentProfileSnapshot map[string]interface{} `json:"agent_profile_snapshot,omitempty"` + ExecutorSnapshot map[string]interface{} `json:"executor_snapshot,omitempty"` + EnvironmentSnapshot map[string]interface{} `json:"environment_snapshot,omitempty"` + RepositorySnapshot map[string]interface{} `json:"repository_snapshot,omitempty"` + State TaskSessionState `json:"state"` + ErrorMessage string `json:"error_message,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + StartedAt time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` // Environment reference TaskEnvironmentID string `json:"task_environment_id,omitempty"` // FK to task_environments for shared env @@ -1348,20 +1364,51 @@ type TaskSession struct { // TODO: Add v1.TaskSession type to pkg/api/v1/ func (s *TaskSession) ToAPI() map[string]interface{} { result := map[string]interface{}{ - "id": s.ID, - "task_id": s.TaskID, - "agent_execution_id": s.AgentExecutionID, - "container_id": s.ContainerID, - "agent_profile_id": s.AgentProfileID, - "executor_id": s.ExecutorID, - "executor_profile_id": s.ExecutorProfileID, - "environment_id": s.EnvironmentID, - "repository_id": s.RepositoryID, - "base_branch": s.BaseBranch, - "base_commit_sha": s.BaseCommitSHA, - "state": string(s.State), - "started_at": s.StartedAt, - "updated_at": s.UpdatedAt, + "id": s.ID, + "task_id": s.TaskID, + "agent_execution_id": s.AgentExecutionID, + "container_id": s.ContainerID, + "agent_profile_id": s.AgentProfileID, + "execution_profile_id": s.ExecutionProfileID, + "executor_id": s.ExecutorID, + "executor_profile_id": s.ExecutorProfileID, + "environment_id": s.EnvironmentID, + "repository_id": s.RepositoryID, + "base_branch": s.BaseBranch, + "base_commit_sha": s.BaseCommitSHA, + "state": string(s.State), + "started_at": s.StartedAt, + "updated_at": s.UpdatedAt, + } + if s.RouteGeneration > 0 { + result["route_generation"] = s.RouteGeneration + } + if s.RouteState != "" { + result["route_state"] = s.RouteState + } + if s.RouteReason != "" { + result["route_reason"] = s.RouteReason + } + if s.DownstreamACPSessionID != "" { + result["downstream_acp_session_id"] = s.DownstreamACPSessionID + } + if s.RouteErrorCode != "" { + result["route_error_code"] = s.RouteErrorCode + } + if s.RouteErrorClass != "" { + result["route_error_class"] = s.RouteErrorClass + } + if s.RouteCatalogueVersion != "" { + result["route_catalogue_version"] = s.RouteCatalogueVersion + } + if s.RouteRetryOrdinal > 0 { + result["route_retry_ordinal"] = s.RouteRetryOrdinal + } + if s.RouteDeadline != nil { + result["route_deadline"] = s.RouteDeadline + } + if s.RoutePendingOutcome != "" { + result["route_pending_outcome"] = s.RoutePendingOutcome } if worktrees := s.WorktreesAPI(); len(worktrees) > 0 { result["worktrees"] = worktrees diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index 638d97a52e..cfae284bc3 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -132,6 +132,12 @@ func (r *Repository) runMigrations() error { return err } r.migrate.Apply("task_sessions.execution_profile_id", `ALTER TABLE task_sessions ADD COLUMN execution_profile_id TEXT NOT NULL DEFAULT ''`) + r.migrate.Apply("task_sessions.route_generation", `ALTER TABLE task_sessions ADD COLUMN route_generation INTEGER NOT NULL DEFAULT 0`) + r.migrate.Apply("task_sessions.route_state", `ALTER TABLE task_sessions ADD COLUMN route_state TEXT NOT NULL DEFAULT ''`) + r.migrate.Apply("task_sessions.route_reason", `ALTER TABLE task_sessions ADD COLUMN route_reason TEXT NOT NULL DEFAULT ''`) + r.migrate.Apply("task_sessions.downstream_acp_session_id", `ALTER TABLE task_sessions ADD COLUMN downstream_acp_session_id TEXT NOT NULL DEFAULT ''`) + r.migrate.Apply("dynamic_route_states.continuation_json", `ALTER TABLE dynamic_route_states ADD COLUMN continuation_json TEXT NOT NULL DEFAULT ''`) + r.migrate.Apply("dynamic_route_states.policy_state_json", `ALTER TABLE dynamic_route_states ADD COLUMN policy_state_json TEXT NOT NULL DEFAULT ''`) r.migrate.Apply("executors_running.execution_profile_id", `ALTER TABLE executors_running ADD COLUMN execution_profile_id TEXT NOT NULL DEFAULT ''`) r.migrate.Apply("executors_running.last_message_uuid", `ALTER TABLE executors_running ADD COLUMN last_message_uuid TEXT DEFAULT ''`) r.migrate.Apply("executors_running.metadata", `ALTER TABLE executors_running ADD COLUMN metadata TEXT DEFAULT '{}'`) @@ -338,6 +344,8 @@ func (r *Repository) runMigrations() error { // frontend snapshots the prior value before the advance to position the // "New" divider (see models.TaskSession.LastReadMessageID). r.migrate.Apply("task_sessions.last_read_message_id", `ALTER TABLE task_sessions ADD COLUMN last_read_message_id TEXT DEFAULT ''`) + r.migrate.Apply("task_session_turns.execution_profile_id", `ALTER TABLE task_session_turns ADD COLUMN execution_profile_id TEXT NOT NULL DEFAULT ''`) + r.migrate.Apply("task_session_turns.route_generation", `ALTER TABLE task_session_turns ADD COLUMN route_generation BIGINT NOT NULL DEFAULT 0`) // Bounded task-level status projection. Keep this on the replay path as well // as the fresh schema path so an existing installation gets the table @@ -909,6 +917,10 @@ func (r *Repository) migrateSessionsRemoveAgentExecutionID() error { task_id TEXT NOT NULL, agent_profile_id TEXT, execution_profile_id TEXT NOT NULL DEFAULT '', + route_generation INTEGER NOT NULL DEFAULT 0, + route_state TEXT NOT NULL DEFAULT '', + route_reason TEXT NOT NULL DEFAULT '', + downstream_acp_session_id TEXT NOT NULL DEFAULT '', executor_id TEXT DEFAULT '', executor_profile_id TEXT DEFAULT '', environment_id TEXT DEFAULT '', @@ -937,6 +949,7 @@ func (r *Repository) migrateSessionsRemoveAgentExecutionID() error { )`, `INSERT INTO task_sessions_new SELECT id, task_id, agent_profile_id, execution_profile_id, + route_generation, route_state, route_reason, downstream_acp_session_id, executor_id, executor_profile_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, @@ -1142,6 +1155,10 @@ func (r *Repository) migrateSessionsRemoveWorkflowStepID() error { container_id TEXT NOT NULL DEFAULT '', agent_profile_id TEXT, execution_profile_id TEXT NOT NULL DEFAULT '', + route_generation INTEGER NOT NULL DEFAULT 0, + route_state TEXT NOT NULL DEFAULT '', + route_reason TEXT NOT NULL DEFAULT '', + downstream_acp_session_id TEXT NOT NULL DEFAULT '', executor_id TEXT DEFAULT '', executor_profile_id TEXT DEFAULT '', environment_id TEXT DEFAULT '', @@ -1166,6 +1183,7 @@ func (r *Repository) migrateSessionsRemoveWorkflowStepID() error { )`, `INSERT INTO task_sessions_new SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, execution_profile_id, + route_generation, route_state, route_reason, downstream_acp_session_id, executor_id, executor_profile_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index c32c9a81f6..e56c2a289e 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -21,6 +21,7 @@ func (r *Repository) initSchema() error { r.initWalkthroughsSchema, r.initDocumentsSchema, r.initSessionSchema, + r.initDynamicRoutingSchema, r.initStepTransitionsSchema, r.initAttachmentsSchema, r.initTaskResourceCleanupSchema, @@ -49,6 +50,55 @@ func (r *Repository) initSchema() error { return nil } +func (r *Repository) initDynamicRoutingSchema() error { + _, err := r.db.Exec(fmt.Sprintf(` + CREATE TABLE IF NOT EXISTS dynamic_route_states ( + session_id TEXT PRIMARY KEY, + logical_profile_id TEXT NOT NULL, + execution_profile_id TEXT NOT NULL DEFAULT '', + route_generation BIGINT NOT NULL DEFAULT 0, + profile_version BIGINT NOT NULL DEFAULT 0, + state TEXT NOT NULL DEFAULT 'selecting', + continuation_json TEXT NOT NULL DEFAULT '', + policy_state_json TEXT NOT NULL DEFAULT '', + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS dynamic_route_attempts ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + logical_profile_id TEXT NOT NULL, + execution_profile_id TEXT NOT NULL DEFAULT '', + route_generation BIGINT NOT NULL, + profile_version BIGINT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + UNIQUE (session_id, route_generation), + FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_dynamic_route_attempts_session + ON dynamic_route_attempts(session_id, route_generation); + + CREATE TABLE IF NOT EXISTS dynamic_resource_circuits ( + resource_key TEXT PRIMARY KEY, + state TEXT NOT NULL, + until_at TIMESTAMP, + code TEXT NOT NULL DEFAULT '', + probe_until TIMESTAMP, + updated_at TIMESTAMP NOT NULL + ); + + CREATE TABLE IF NOT EXISTS dynamic_installation_keys ( + id INTEGER PRIMARY KEY CHECK (id = 1), + key_bytes %s NOT NULL, + created_at TIMESTAMP NOT NULL + ); + `, dialect.BlobType(r.db.DriverName()))) + return err +} + const taskResourceCleanupSchemaDDL = ` CREATE TABLE IF NOT EXISTS task_resource_cleanup_jobs ( id TEXT PRIMARY KEY, @@ -780,6 +830,8 @@ func (r *Repository) initMessageTurnSchema() error { task_id TEXT NOT NULL, started_at TIMESTAMP NOT NULL, completed_at TIMESTAMP, + execution_profile_id TEXT NOT NULL DEFAULT '', + route_generation BIGINT NOT NULL DEFAULT 0, metadata TEXT DEFAULT '{}', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, @@ -839,6 +891,10 @@ const sessionWorktreeSchemaDDL = ` container_id TEXT NOT NULL DEFAULT '', agent_profile_id TEXT, execution_profile_id TEXT NOT NULL DEFAULT '', + route_generation INTEGER NOT NULL DEFAULT 0, + route_state TEXT NOT NULL DEFAULT '', + route_reason TEXT NOT NULL DEFAULT '', + downstream_acp_session_id TEXT NOT NULL DEFAULT '', executor_id TEXT DEFAULT '', executor_profile_id TEXT DEFAULT '', environment_id TEXT DEFAULT '', diff --git a/apps/backend/internal/task/repository/sqlite/dynamic_route.go b/apps/backend/internal/task/repository/sqlite/dynamic_route.go new file mode 100644 index 0000000000..6876c3636b --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/dynamic_route.go @@ -0,0 +1,366 @@ +package sqlite + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/google/uuid" + + dynamicruntime "github.com/kandev/kandev/internal/agent/runtime/dynamic" + "github.com/kandev/kandev/internal/agent/runtime/routingerr" +) + +var _ dynamicruntime.Persistence = (*Repository)(nil) +var _ dynamicruntime.ContinuationPersistence = (*Repository)(nil) + +func (r *Repository) SaveRouteState(ctx context.Context, state dynamicruntime.RouteState) error { + if isTransientRouteSession(state.SessionID) { + return nil + } + updatedAt := state.UpdatedAt + if updatedAt.IsZero() { + updatedAt = time.Now().UTC() + } + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO dynamic_route_states ( + session_id, logical_profile_id, execution_profile_id, + route_generation, profile_version, state, continuation_json, policy_state_json, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + logical_profile_id = excluded.logical_profile_id, + execution_profile_id = excluded.execution_profile_id, + route_generation = excluded.route_generation, + profile_version = excluded.profile_version, + state = excluded.state, + continuation_json = excluded.continuation_json, + policy_state_json = excluded.policy_state_json, + updated_at = excluded.updated_at + `), state.SessionID, state.LogicalProfileID, state.ExecutionProfileID, + state.Generation, state.ProfileVersion, state.Status, state.ContinuationJSON, state.PolicyStateJSON, updatedAt) + return err +} + +// ClaimRouteState advances a route generation only when the durable row still +// has the caller's expected generation. The insert path is reserved for the +// initial generation, so a restart cannot accidentally reset an existing +// session to generation one. +func (r *Repository) ClaimRouteState(ctx context.Context, expectedGeneration int64, state dynamicruntime.RouteState) (bool, error) { + if isTransientRouteSession(state.SessionID) { + return true, nil + } + var ( + result sql.Result + err error + ) + if expectedGeneration == 0 { + result, err = r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO dynamic_route_states ( + session_id, logical_profile_id, execution_profile_id, + route_generation, profile_version, state, continuation_json, policy_state_json, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO NOTHING + `), state.SessionID, state.LogicalProfileID, state.ExecutionProfileID, + state.Generation, state.ProfileVersion, state.Status, state.ContinuationJSON, state.PolicyStateJSON, state.UpdatedAt) + } else { + result, err = r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE dynamic_route_states + SET logical_profile_id = ?, execution_profile_id = ?, + route_generation = ?, profile_version = ?, state = ?, continuation_json = ?, policy_state_json = ?, updated_at = ? + WHERE session_id = ? AND route_generation = ? + `), state.LogicalProfileID, state.ExecutionProfileID, state.Generation, + state.ProfileVersion, state.Status, state.ContinuationJSON, state.PolicyStateJSON, state.UpdatedAt, state.SessionID, + expectedGeneration) + } + if err != nil { + return false, err + } + rows, err := result.RowsAffected() + return rows == 1, err +} + +// RecordRouteDecision commits the generation claim and immutable attempt row +// together. A stale claim returns dynamicruntime.ErrStaleGeneration. +func (r *Repository) RecordRouteDecision(ctx context.Context, decision dynamicruntime.RouteDecision, state dynamicruntime.RouteState) error { + if isTransientRouteSession(state.SessionID) { + return nil + } + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + expectedGeneration := state.Generation - 1 + var result sql.Result + if expectedGeneration == 0 { + result, err = tx.ExecContext(ctx, r.db.Rebind(` + INSERT INTO dynamic_route_states ( + session_id, logical_profile_id, execution_profile_id, + route_generation, profile_version, state, continuation_json, policy_state_json, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO NOTHING + `), state.SessionID, state.LogicalProfileID, state.ExecutionProfileID, + state.Generation, state.ProfileVersion, state.Status, state.ContinuationJSON, state.PolicyStateJSON, state.UpdatedAt) + } else { + result, err = tx.ExecContext(ctx, r.db.Rebind(` + UPDATE dynamic_route_states + SET logical_profile_id = ?, execution_profile_id = ?, + route_generation = ?, profile_version = ?, state = ?, continuation_json = ?, policy_state_json = ?, updated_at = ? + WHERE session_id = ? AND route_generation = ? + `), state.LogicalProfileID, state.ExecutionProfileID, state.Generation, + state.ProfileVersion, state.Status, state.ContinuationJSON, state.PolicyStateJSON, state.UpdatedAt, state.SessionID, + expectedGeneration) + } + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return dynamicruntime.ErrStaleGeneration + } + createdAt := decisionReasonTime(decision, state) + if _, err := tx.ExecContext(ctx, r.db.Rebind(` + INSERT INTO dynamic_route_attempts ( + id, session_id, logical_profile_id, execution_profile_id, + route_generation, profile_version, reason, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `), uuid.New().String(), decision.SessionID, decision.LogicalProfileID, + decision.ExecutionProfileID, decision.Generation, decision.ProfileVersion, + decision.Reason, createdAt); err != nil { + return err + } + return tx.Commit() +} + +func decisionReasonTime(_ dynamicruntime.RouteDecision, state dynamicruntime.RouteState) time.Time { + if !state.UpdatedAt.IsZero() { + return state.UpdatedAt + } + return time.Now().UTC() +} + +func (r *Repository) AppendRouteAttempt(ctx context.Context, attempt dynamicruntime.RouteAttempt) error { + if isTransientRouteSession(attempt.SessionID) { + return nil + } + createdAt := attempt.CreatedAt + if createdAt.IsZero() { + createdAt = time.Now().UTC() + } + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO dynamic_route_attempts ( + id, session_id, logical_profile_id, execution_profile_id, + route_generation, profile_version, reason, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `), uuid.New().String(), attempt.SessionID, attempt.LogicalProfileID, + attempt.ExecutionProfileID, attempt.Generation, attempt.ProfileVersion, + attempt.Reason, createdAt) + return err +} + +func (r *Repository) LoadRouteState(ctx context.Context, sessionID string) (*dynamicruntime.RouteState, error) { + if isTransientRouteSession(sessionID) { + return nil, nil + } + state := &dynamicruntime.RouteState{} + err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` + SELECT session_id, logical_profile_id, execution_profile_id, + route_generation, profile_version, state, continuation_json, policy_state_json, updated_at + FROM dynamic_route_states WHERE session_id = ? + `), sessionID).Scan( + &state.SessionID, &state.LogicalProfileID, &state.ExecutionProfileID, + &state.Generation, &state.ProfileVersion, &state.Status, &state.ContinuationJSON, &state.PolicyStateJSON, &state.UpdatedAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return state, nil +} + +// ListPendingRouteStates returns only states whose durable policy deadline can +// be reconciled automatically. States marked retrying are intentionally not +// returned after restart because dispatch may already have crossed the +// process boundary and must remain a manual recovery decision. +func (r *Repository) ListPendingRouteStates(ctx context.Context) ([]dynamicruntime.RouteState, error) { + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT session_id, logical_profile_id, execution_profile_id, + route_generation, profile_version, state, continuation_json, policy_state_json, updated_at + FROM dynamic_route_states + WHERE state IN (?, ?) ORDER BY updated_at ASC + `), string("retry_wait"), string("waiting_for_reset")) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + states := make([]dynamicruntime.RouteState, 0) + for rows.Next() { + var state dynamicruntime.RouteState + if err := rows.Scan( + &state.SessionID, &state.LogicalProfileID, &state.ExecutionProfileID, + &state.Generation, &state.ProfileVersion, &state.Status, + &state.ContinuationJSON, &state.PolicyStateJSON, &state.UpdatedAt, + ); err != nil { + return nil, err + } + states = append(states, state) + } + if err := rows.Err(); err != nil { + return nil, err + } + return states, nil +} + +func (r *Repository) SaveRouteContinuation(ctx context.Context, record dynamicruntime.ContinuationRecord) error { + if isTransientRouteSession(record.SessionID) { + return nil + } + payload, err := json.Marshal(record.Continuation) + if err != nil { + return err + } + updatedAt := record.UpdatedAt + if updatedAt.IsZero() { + updatedAt = time.Now().UTC() + } + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE dynamic_route_states + SET continuation_json = ?, updated_at = ? + WHERE session_id = ? AND route_generation = ? + `), string(payload), updatedAt, record.SessionID, record.Generation) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return dynamicruntime.ErrStaleGeneration + } + return nil +} + +func (r *Repository) SaveCircuit(ctx context.Context, snapshot dynamicruntime.CircuitSnapshot) error { + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO dynamic_resource_circuits + (resource_key, state, until_at, code, probe_until, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(resource_key) DO UPDATE SET + state = excluded.state, + until_at = excluded.until_at, + code = excluded.code, + probe_until = excluded.probe_until, + updated_at = excluded.updated_at + `), snapshot.Key, snapshot.State, nullableTime(snapshot.Until), snapshot.Code, + nullableTime(snapshot.ProbeUntil), time.Now().UTC()) + return err +} + +func (r *Repository) LoadCircuits(ctx context.Context) ([]dynamicruntime.CircuitSnapshot, error) { + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT resource_key, state, until_at, code, probe_until + FROM dynamic_resource_circuits + WHERE state <> ? ORDER BY resource_key + `), dynamicruntime.CircuitClosed) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var snapshots []dynamicruntime.CircuitSnapshot + for rows.Next() { + var snapshot dynamicruntime.CircuitSnapshot + var until, probeUntil sql.NullTime + var state string + var code string + if err := rows.Scan(&snapshot.Key, &state, &until, &code, &probeUntil); err != nil { + return nil, err + } + snapshot.State = dynamicruntime.CircuitState(state) + snapshot.Code = routingerr.Code(code) + if until.Valid { + snapshot.Until = until.Time + } + if probeUntil.Valid { + snapshot.ProbeUntil = probeUntil.Time + } + snapshots = append(snapshots, snapshot) + } + return snapshots, rows.Err() +} + +func (r *Repository) LoadOrCreate(ctx context.Context) ([]byte, error) { + var key []byte + err := r.ro.QueryRowContext(ctx, `SELECT key_bytes FROM dynamic_installation_keys WHERE id = 1`).Scan(&key) + if err == nil { + return append([]byte(nil), key...), nil + } + if !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + key = make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, err + } + _, err = r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO dynamic_installation_keys (id, key_bytes, created_at) VALUES (1, ?, ?) + `), key, time.Now().UTC()) + if err != nil { + // Another process may have won the first insert. Read its stable key. + if readErr := r.ro.QueryRowContext(ctx, `SELECT key_bytes FROM dynamic_installation_keys WHERE id = 1`).Scan(&key); readErr == nil { + return append([]byte(nil), key...), nil + } + return nil, err + } + return key, nil +} + +func nullableTime(value time.Time) interface{} { + if value.IsZero() { + return nil + } + return value +} + +func (r *Repository) ListRouteAttempts(ctx context.Context, sessionID string) ([]dynamicruntime.RouteAttempt, error) { + if isTransientRouteSession(sessionID) { + return []dynamicruntime.RouteAttempt{}, nil + } + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT session_id, logical_profile_id, execution_profile_id, + route_generation, profile_version, reason, created_at + FROM dynamic_route_attempts + WHERE session_id = ? ORDER BY route_generation ASC + `), sessionID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + attempts := make([]dynamicruntime.RouteAttempt, 0) + for rows.Next() { + var attempt dynamicruntime.RouteAttempt + if err := rows.Scan(&attempt.SessionID, &attempt.LogicalProfileID, + &attempt.ExecutionProfileID, &attempt.Generation, &attempt.ProfileVersion, + &attempt.Reason, &attempt.CreatedAt); err != nil { + return nil, err + } + attempts = append(attempts, attempt) + } + if err := rows.Err(); err != nil { + return nil, err + } + return attempts, nil +} + +func isTransientRouteSession(sessionID string) bool { + return strings.HasPrefix(sessionID, "utility:") +} diff --git a/apps/backend/internal/task/repository/sqlite/dynamic_route_test.go b/apps/backend/internal/task/repository/sqlite/dynamic_route_test.go new file mode 100644 index 0000000000..fdb804f979 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/dynamic_route_test.go @@ -0,0 +1,295 @@ +package sqlite + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + dynamicruntime "github.com/kandev/kandev/internal/agent/runtime/dynamic" + "github.com/kandev/kandev/internal/task/models" +) + +func TestDynamicRouteStateAndAttemptsPersistAcrossRepositoryReads(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + if err := repo.CreateTask(ctx, &models.Task{ID: "task-dynamic-route", Title: "Dynamic route"}); err != nil { + t.Fatalf("CreateTask: %v", err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-dynamic-route", TaskID: "task-dynamic-route", + State: models.TaskSessionStateStarting, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + now := time.Now().UTC() + state := dynamicruntime.RouteState{ + SessionID: "session-dynamic-route", LogicalProfileID: "dynamic-1", + ExecutionProfileID: "concrete-1", Generation: 7, ProfileVersion: 3, + Status: "active", PolicyStateJSON: `{"retry_ordinal":2,"pending_outcome":"skip"}`, UpdatedAt: now, + } + if err := repo.SaveRouteState(ctx, state); err != nil { + t.Fatalf("SaveRouteState: %v", err) + } + continuation := dynamicruntime.Continuation{ + TaskDescription: "continue the migration", + Conversation: "The predecessor completed the schema scan.", + } + if err := repo.SaveRouteContinuation(ctx, dynamicruntime.ContinuationRecord{ + SessionID: state.SessionID, Generation: state.Generation, Continuation: continuation, + }); err != nil { + t.Fatalf("SaveRouteContinuation: %v", err) + } + if err := repo.SaveRouteContinuation(ctx, dynamicruntime.ContinuationRecord{ + SessionID: state.SessionID, Generation: state.Generation - 1, Continuation: continuation, + }); !errors.Is(err, dynamicruntime.ErrStaleGeneration) { + t.Fatalf("stale SaveRouteContinuation error = %v", err) + } + if err := repo.AppendRouteAttempt(ctx, dynamicruntime.RouteAttempt{ + SessionID: "session-dynamic-route", LogicalProfileID: "dynamic-1", + ExecutionProfileID: "concrete-1", Generation: 7, ProfileVersion: 3, + Reason: "candidate_order", CreatedAt: now, + }); err != nil { + t.Fatalf("AppendRouteAttempt: %v", err) + } + loaded, err := repo.LoadRouteState(ctx, "session-dynamic-route") + if err != nil { + t.Fatalf("LoadRouteState: %v", err) + } + if loaded == nil { + t.Fatal("loaded state is nil") + return + } + if loaded.Generation != 7 || loaded.ExecutionProfileID != "concrete-1" { + t.Fatalf("loaded state = %#v", loaded) + } + if loaded.PolicyStateJSON != state.PolicyStateJSON { + t.Fatalf("loaded policy state = %q, want %q", loaded.PolicyStateJSON, state.PolicyStateJSON) + } + var loadedContinuation dynamicruntime.Continuation + if err := json.Unmarshal([]byte(loaded.ContinuationJSON), &loadedContinuation); err != nil { + t.Fatalf("decode continuation: %v", err) + } + if loadedContinuation.TaskDescription != continuation.TaskDescription || + loadedContinuation.Conversation != continuation.Conversation { + t.Fatalf("loaded continuation = %#v", loadedContinuation) + } + attempts, err := repo.ListRouteAttempts(ctx, "session-dynamic-route") + if err != nil { + t.Fatalf("ListRouteAttempts: %v", err) + } + if len(attempts) != 1 || attempts[0].Generation != 7 { + t.Fatalf("loaded attempts = %#v", attempts) + } +} + +func TestClaimRouteStateDoesNotInsertAfterRestartWithNonZeroExpectation(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + state := dynamicruntime.RouteState{ + SessionID: "missing-session", LogicalProfileID: "dynamic-1", + ExecutionProfileID: "concrete-1", Generation: 2, ProfileVersion: 1, + Status: "starting", UpdatedAt: time.Now().UTC(), + } + claimed, err := repo.ClaimRouteState(ctx, 1, state) + if err != nil { + t.Fatalf("ClaimRouteState: %v", err) + } + if claimed { + t.Fatal("claim inserted a route when the expected generation had no durable predecessor") + } + loaded, err := repo.LoadRouteState(ctx, state.SessionID) + if err != nil { + t.Fatalf("LoadRouteState: %v", err) + } + if loaded != nil { + t.Fatalf("stale claim created state: %#v", loaded) + } +} + +func TestListPendingRouteStatesExcludesAmbiguousRetryingStates(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + now := time.Now().UTC() + for _, sessionID := range []string{"pending-retry", "pending-reset", "ambiguous-retry"} { + taskID := "task-" + sessionID + if err := repo.CreateTask(ctx, &models.Task{ID: taskID, Title: sessionID}); err != nil { + t.Fatalf("CreateTask(%s): %v", taskID, err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: sessionID, TaskID: taskID, State: models.TaskSessionStateWaitingForInput}); err != nil { + t.Fatalf("CreateTaskSession(%s): %v", sessionID, err) + } + } + for _, state := range []dynamicruntime.RouteState{ + {SessionID: "pending-retry", LogicalProfileID: "dynamic", Generation: 1, Status: "retry_wait", PolicyStateJSON: `{"deadline":"2099-01-01T00:00:00Z"}`, UpdatedAt: now}, + {SessionID: "pending-reset", LogicalProfileID: "dynamic", Generation: 2, Status: "waiting_for_reset", PolicyStateJSON: `{"deadline":"2099-01-01T00:00:00Z"}`, UpdatedAt: now.Add(time.Second)}, + {SessionID: "ambiguous-retry", LogicalProfileID: "dynamic", Generation: 3, Status: "retrying", PolicyStateJSON: `{"deadline":"2099-01-01T00:00:00Z"}`, UpdatedAt: now.Add(2 * time.Second)}, + } { + if err := repo.SaveRouteState(ctx, state); err != nil { + t.Fatalf("SaveRouteState(%s): %v", state.SessionID, err) + } + } + states, err := repo.ListPendingRouteStates(ctx) + if err != nil { + t.Fatalf("ListPendingRouteStates: %v", err) + } + if len(states) != 2 || states[0].SessionID != "pending-retry" || states[1].SessionID != "pending-reset" { + t.Fatalf("pending states = %#v", states) + } +} + +func TestClaimRouteStateAdvancesGenerationWithFence(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + if err := repo.CreateTask(ctx, &models.Task{ID: "task-claim-route", Title: "Claim route"}); err != nil { + t.Fatalf("CreateTask: %v", err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "claim-session", TaskID: "task-claim-route", State: models.TaskSessionStateStarting, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + now := time.Now().UTC() + state := dynamicruntime.RouteState{ + SessionID: "claim-session", LogicalProfileID: "dynamic-1", + ExecutionProfileID: "concrete-1", Generation: 1, ProfileVersion: 1, + Status: "starting", UpdatedAt: now, + } + claimed, err := repo.ClaimRouteState(ctx, 0, state) + if err != nil || !claimed { + t.Fatalf("initial ClaimRouteState = %v, %v", claimed, err) + } + state.ExecutionProfileID = "concrete-2" + state.Generation = 2 + state.UpdatedAt = now.Add(time.Second) + claimed, err = repo.ClaimRouteState(ctx, 1, state) + if err != nil || !claimed { + t.Fatalf("successor ClaimRouteState = %v, %v", claimed, err) + } + state.ExecutionProfileID = "concrete-3" + state.Generation = 3 + claimed, err = repo.ClaimRouteState(ctx, 1, state) + if err != nil { + t.Fatalf("stale ClaimRouteState: %v", err) + } + if claimed { + t.Fatal("stale claim advanced the route") + } + loaded, err := repo.LoadRouteState(ctx, "claim-session") + if err != nil { + t.Fatalf("LoadRouteState: %v", err) + } + if loaded == nil || loaded.Generation != 2 || loaded.ExecutionProfileID != "concrete-2" { + t.Fatalf("claimed state = %#v", loaded) + } +} + +func TestRecordRouteDecisionClaimsInitialGenerationAndWritesAttemptAtomically(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + if err := repo.CreateTask(ctx, &models.Task{ID: "task-atomic-route", Title: "Atomic route"}); err != nil { + t.Fatalf("CreateTask: %v", err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "atomic-session", TaskID: "task-atomic-route", State: models.TaskSessionStateStarting, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + now := time.Now().UTC() + decision := dynamicruntime.RouteDecision{ + SessionID: "atomic-session", LogicalProfileID: "dynamic-1", + ExecutionProfileID: "concrete-1", Generation: 1, ProfileVersion: 2, + Reason: "candidate_order", + } + state := dynamicruntime.RouteState{ + SessionID: decision.SessionID, LogicalProfileID: decision.LogicalProfileID, + ExecutionProfileID: decision.ExecutionProfileID, Generation: decision.Generation, + ProfileVersion: decision.ProfileVersion, Status: "starting", UpdatedAt: now, + } + if err := repo.RecordRouteDecision(ctx, decision, state); err != nil { + t.Fatalf("RecordRouteDecision: %v", err) + } + loaded, err := repo.LoadRouteState(ctx, decision.SessionID) + if err != nil { + t.Fatalf("LoadRouteState: %v", err) + } + if loaded == nil || loaded.Generation != 1 { + t.Fatalf("loaded state = %#v", loaded) + } + attempts, err := repo.ListRouteAttempts(ctx, decision.SessionID) + if err != nil { + t.Fatalf("ListRouteAttempts: %v", err) + } + if len(attempts) != 1 || attempts[0].ExecutionProfileID != decision.ExecutionProfileID { + t.Fatalf("loaded attempts = %#v", attempts) + } + decision2 := decision + decision2.ExecutionProfileID = "concrete-2" + decision2.Generation = 2 + decision2.Reason = "try_next" + state2 := state + state2.ExecutionProfileID = decision2.ExecutionProfileID + state2.Generation = decision2.Generation + state2.UpdatedAt = now.Add(time.Second) + if err := repo.RecordRouteDecision(ctx, decision2, state2); err != nil { + t.Fatalf("RecordRouteDecision successor: %v", err) + } + loaded, err = repo.LoadRouteState(ctx, decision.SessionID) + if err != nil { + t.Fatalf("LoadRouteState successor: %v", err) + } + if loaded == nil || loaded.Generation != 2 || loaded.ExecutionProfileID != "concrete-2" { + t.Fatalf("successor state = %#v", loaded) + } + attempts, err = repo.ListRouteAttempts(ctx, decision.SessionID) + if err != nil { + t.Fatalf("ListRouteAttempts successor: %v", err) + } + if len(attempts) != 2 || attempts[1].Generation != 2 { + t.Fatalf("successor attempts = %#v", attempts) + } +} + +func TestUtilityRouteStateIsTransient(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + now := time.Now().UTC() + decision := dynamicruntime.RouteDecision{ + SessionID: "utility:test", LogicalProfileID: "dynamic-1", + ExecutionProfileID: "concrete-1", Generation: 1, ProfileVersion: 1, + Reason: "candidate_order", + } + state := dynamicruntime.RouteState{ + SessionID: decision.SessionID, LogicalProfileID: decision.LogicalProfileID, + ExecutionProfileID: decision.ExecutionProfileID, Generation: 1, + ProfileVersion: 1, Status: "starting", UpdatedAt: now, + } + if err := repo.SaveRouteState(ctx, state); err != nil { + t.Fatalf("SaveRouteState: %v", err) + } + if err := repo.AppendRouteAttempt(ctx, dynamicruntime.RouteAttempt{SessionID: decision.SessionID}); err != nil { + t.Fatalf("AppendRouteAttempt: %v", err) + } + if err := repo.RecordRouteDecision(ctx, decision, state); err != nil { + t.Fatalf("RecordRouteDecision: %v", err) + } + claimed, err := repo.ClaimRouteState(ctx, 0, state) + if err != nil || !claimed { + t.Fatalf("ClaimRouteState = %v, %v", claimed, err) + } + loaded, err := repo.LoadRouteState(ctx, decision.SessionID) + if err != nil { + t.Fatalf("LoadRouteState: %v", err) + } + if loaded != nil { + t.Fatalf("utility route state persisted: %#v", loaded) + } + attempts, err := repo.ListRouteAttempts(ctx, decision.SessionID) + if err != nil { + t.Fatalf("ListRouteAttempts: %v", err) + } + if len(attempts) != 0 { + t.Fatalf("utility route attempts = %#v", attempts) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go b/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go index d748f2322a..ba6e0b39d7 100644 --- a/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go +++ b/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go @@ -17,6 +17,31 @@ import ( "github.com/kandev/kandev/internal/testutil" ) +// TestPostgresDynamicInstallationKeyUsesBytea verifies the PostgreSQL schema +// branch for the installation binding key. SQLite accepts BLOB, while +// PostgreSQL requires BYTEA. Skips unless KANDEV_TEST_POSTGRES_DSN is set. +func TestPostgresDynamicInstallationKeyUsesBytea(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + if _, err := NewWithDB(db, db, nil); err != nil { + t.Fatalf("init postgres schema: %v", err) + } + + var dataType string + err := db.QueryRowContext(context.Background(), ` + SELECT data_type + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'dynamic_installation_keys' + AND column_name = 'key_bytes' + `).Scan(&dataType) + if err != nil { + t.Fatalf("inspect dynamic_installation_keys.key_bytes: %v", err) + } + if dataType != "bytea" { + t.Fatalf("dynamic_installation_keys.key_bytes data type = %q, want bytea", dataType) + } +} + // TestPostgresExecutorRunningLocalPIDMigration is the Postgres counterpart to // TestExecutorRunningLocalPIDMigrationOnLegacyDB (SQLite): local_pid is on the // shared migration path, so ADR 0027 asks for env-gated Postgres replay coverage diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 098a5f28c9..0d3c0f0b3d 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -113,9 +113,9 @@ func (r *Repository) insertTurnRow(ctx context.Context, execer taskSessionExecut } _, err := execer.ExecContext(ctx, r.db.Rebind(` - INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `), turn.ID, turn.TaskSessionID, turn.TaskID, turn.StartedAt, turn.CompletedAt, metadataJSON, turn.CreatedAt, turn.UpdatedAt) + INSERT INTO task_session_turns (id, task_session_id, task_id, execution_profile_id, route_generation, started_at, completed_at, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), turn.ID, turn.TaskSessionID, turn.TaskID, turn.ExecutionProfileID, turn.RouteGeneration, turn.StartedAt, turn.CompletedAt, metadataJSON, turn.CreatedAt, turn.UpdatedAt) return err } @@ -188,7 +188,7 @@ func scanTurn(scanner turnScanner) (*models.Turn, error) { turn := &models.Turn{} var metadataJSON string var completedAt sql.NullTime - err := scanner.Scan(&turn.ID, &turn.TaskSessionID, &turn.TaskID, &turn.StartedAt, &completedAt, &metadataJSON, &turn.CreatedAt, &turn.UpdatedAt) + err := scanner.Scan(&turn.ID, &turn.TaskSessionID, &turn.TaskID, &turn.ExecutionProfileID, &turn.RouteGeneration, &turn.StartedAt, &completedAt, &metadataJSON, &turn.CreatedAt, &turn.UpdatedAt) if err != nil { return nil, err } @@ -210,7 +210,7 @@ func scanTurnRow(row *sql.Row) (*models.Turn, error) { // GetTurn retrieves a turn by ID func (r *Repository) GetTurn(ctx context.Context, id string) (*models.Turn, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at + SELECT id, task_session_id, task_id, execution_profile_id, route_generation, started_at, completed_at, metadata, created_at, updated_at FROM task_session_turns WHERE id = ? `), id) return scanTurnRow(row) @@ -219,7 +219,7 @@ func (r *Repository) GetTurn(ctx context.Context, id string) (*models.Turn, erro // GetActiveTurnBySessionID gets the currently active (non-completed) turn for a session func (r *Repository) GetActiveTurnBySessionID(ctx context.Context, sessionID string) (*models.Turn, error) { query := fmt.Sprintf(` - SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at + SELECT id, task_session_id, task_id, execution_profile_id, route_generation, started_at, completed_at, metadata, created_at, updated_at FROM task_session_turns turn_row WHERE turn_row.task_session_id = ? AND turn_row.completed_at IS NULL @@ -248,9 +248,9 @@ func (r *Repository) UpdateTurn(ctx context.Context, turn *models.Turn) error { updatedAt := r.nowUTC() result, err := tx.ExecContext(ctx, r.db.Rebind(` UPDATE task_session_turns - SET completed_at = ?, metadata = ?, updated_at = ? + SET completed_at = ?, metadata = ?, execution_profile_id = ?, route_generation = ?, updated_at = ? WHERE id = ? AND task_session_id = ? AND updated_at = ? - `), turn.CompletedAt, metadataJSON, updatedAt, turn.ID, turn.TaskSessionID, turn.UpdatedAt) + `), turn.CompletedAt, metadataJSON, turn.ExecutionProfileID, turn.RouteGeneration, updatedAt, turn.ID, turn.TaskSessionID, turn.UpdatedAt) if err != nil { return err } @@ -429,7 +429,7 @@ func (r *Repository) ListTurnsBySession(ctx context.Context, sessionID string) ( ctx, span := tracing.Tracer("kandev-db").Start(ctx, "db.ListTurnsBySession") defer span.End() query := fmt.Sprintf(` - SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at + SELECT id, task_session_id, task_id, execution_profile_id, route_generation, started_at, completed_at, metadata, created_at, updated_at FROM task_session_turns turn_row WHERE turn_row.task_session_id = ? AND %s ORDER BY turn_row.started_at ASC, turn_row.created_at ASC, turn_row.id ASC @@ -469,7 +469,8 @@ func (r *Repository) ListTurnsBySession(ctx context.Context, sessionID string) ( // the two column names that used to live here have collapsed into one. const taskSessionSelectCols = `ts.id, ts.task_id, COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''), - ts.agent_profile_id, ts.execution_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id, + ts.agent_profile_id, ts.execution_profile_id, ts.route_generation, ts.route_state, ts.route_reason, ts.downstream_acp_session_id, + ts.executor_id, ts.executor_profile_id, ts.environment_id, ts.repository_id, ts.base_branch, ts.base_commit_sha, COALESCE(NULLIF(te.workspace_path, ''), ts.workspace_path), ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot, @@ -676,14 +677,20 @@ func (r *Repository) createTaskSession(ctx context.Context, exec taskSessionExec } _, err = exec.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_sessions ( - id, task_id, agent_profile_id, execution_profile_id, executor_id, executor_profile_id, environment_id, + id, task_id, agent_profile_id, execution_profile_id, route_generation, route_state, route_reason, downstream_acp_session_id, + executor_id, executor_profile_id, environment_id, repository_id, base_branch, base_commit_sha, workspace_path, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, is_primary, review_status, is_passthrough, task_environment_id, name - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ) `), session.ID, session.TaskID, agentProfileID, - session.ExecutionProfileID, session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, session.BaseCommitSHA, session.WorkspacePath, + session.ExecutionProfileID, session.RouteGeneration, session.RouteState, session.RouteReason, session.DownstreamACPSessionID, + session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, session.BaseCommitSHA, session.WorkspacePath, string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), string(session.State), session.ErrorMessage, string(metadataJSON), session.StartedAt, session.CompletedAt, session.UpdatedAt, @@ -730,7 +737,8 @@ func (r *Repository) scanTaskSession(ctx context.Context, row *sql.Row, noRowsEr err := row.Scan( &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &agentProfileID, - &session.ExecutionProfileID, &session.ExecutorID, &session.ExecutorProfileID, &session.EnvironmentID, + &session.ExecutionProfileID, &session.RouteGeneration, &session.RouteState, &session.RouteReason, &session.DownstreamACPSessionID, + &session.ExecutorID, &session.ExecutorProfileID, &session.EnvironmentID, &session.RepositoryID, &session.BaseBranch, &session.BaseCommitSHA, &session.WorkspacePath, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, @@ -783,10 +791,54 @@ func (r *Repository) scanTaskSession(ctx context.Context, row *sql.Row, noRowsEr return nil, fmt.Errorf("failed to load session worktrees: %w", err) } session.Worktrees = worktrees + if err := r.hydrateDynamicRoutePolicy(ctx, session); err != nil { + return nil, err + } return session, nil } +// hydrateDynamicRoutePolicy projects the durable route decision into session +// responses. The route row remains the source of truth, so this projection +// survives a backend restart without adding a second mutable policy store to +// task_sessions. +func (r *Repository) hydrateDynamicRoutePolicy(ctx context.Context, session *models.TaskSession) error { + if session == nil || session.RouteGeneration <= 0 { + return nil + } + var policyStateJSON string + err := r.ro.QueryRowxContext(ctx, r.ro.Rebind( + `SELECT policy_state_json FROM dynamic_route_states WHERE session_id = ?`, + ), session.ID).Scan(&policyStateJSON) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return fmt.Errorf("failed to load dynamic route policy projection: %w", err) + } + var projection struct { + FailureCode string `json:"failure_code"` + FailureClass string `json:"failure_class"` + CatalogueVersion string `json:"catalogue_version"` + RetryOrdinal int64 `json:"retry_ordinal"` + Deadline *time.Time `json:"deadline"` + PendingOutcome string `json:"pending_outcome"` + } + if policyStateJSON == "" { + return nil + } + if err := json.Unmarshal([]byte(policyStateJSON), &projection); err != nil { + return fmt.Errorf("failed to decode dynamic route policy projection: %w", err) + } + session.RouteErrorCode = projection.FailureCode + session.RouteErrorClass = projection.FailureClass + session.RouteCatalogueVersion = projection.CatalogueVersion + session.RouteRetryOrdinal = projection.RetryOrdinal + session.RouteDeadline = projection.Deadline + session.RoutePendingOutcome = projection.PendingOutcome + return nil +} + // GetTaskSession retrieves an agent session by ID func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind( @@ -1118,13 +1170,15 @@ func (r *Repository) updateTaskSessionWithStateGuard( } query := ` UPDATE task_sessions SET - agent_profile_id = ?, execution_profile_id = ?, executor_id = ?, executor_profile_id = ?, environment_id = ?, + agent_profile_id = ?, execution_profile_id = ?, route_generation = ?, route_state = ?, route_reason = ?, downstream_acp_session_id = ?, + executor_id = ?, executor_profile_id = ?, environment_id = ?, repository_id = ?, base_branch = ?, base_commit_sha = ?, workspace_path = ?, agent_profile_snapshot = ?, executor_snapshot = ?, environment_snapshot = ?, repository_snapshot = ?, state = ?, error_message = ?, completed_at = ?, updated_at = ?, is_primary = ?, review_status = ?, is_passthrough = ?, task_environment_id = ? WHERE id = ?` - args := []interface{}{agentProfileID, session.ExecutionProfileID, session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID, + args := []interface{}{agentProfileID, session.ExecutionProfileID, session.RouteGeneration, session.RouteState, session.RouteReason, session.DownstreamACPSessionID, + session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, session.BaseCommitSHA, session.WorkspacePath, string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), string(session.State), session.ErrorMessage, session.CompletedAt, session.UpdatedAt, @@ -2317,7 +2371,8 @@ func scanTaskSessionRow(rows *sql.Rows) (*models.TaskSession, error) { err := rows.Scan( &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &agentProfileID, - &session.ExecutionProfileID, &session.ExecutorID, &session.ExecutorProfileID, &session.EnvironmentID, + &session.ExecutionProfileID, &session.RouteGeneration, &session.RouteState, &session.RouteReason, &session.DownstreamACPSessionID, + &session.ExecutorID, &session.ExecutorProfileID, &session.EnvironmentID, &session.RepositoryID, &session.BaseBranch, &session.BaseCommitSHA, &session.WorkspacePath, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, diff --git a/apps/backend/internal/task/repository/sqlite/turn_reservation_recovery.go b/apps/backend/internal/task/repository/sqlite/turn_reservation_recovery.go index 676993749e..9c777b8079 100644 --- a/apps/backend/internal/task/repository/sqlite/turn_reservation_recovery.go +++ b/apps/backend/internal/task/repository/sqlite/turn_reservation_recovery.go @@ -57,7 +57,7 @@ func (r *Repository) ReconcileUnpublishedPromptTurns(ctx context.Context) (int, // the marker through ClearTurnPromptDispatchMetadata. func (r *Repository) ListTurnsPendingStartEvent(ctx context.Context) ([]*models.Turn, error) { query := fmt.Sprintf(` - SELECT id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at + SELECT id, task_session_id, task_id, execution_profile_id, route_generation, started_at, completed_at, metadata, created_at, updated_at FROM task_session_turns turn_row WHERE %s ORDER BY turn_row.task_session_id, turn_row.started_at, turn_row.created_at, turn_row.id diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index f2745ba9d0..c087a7d56d 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -73,13 +73,15 @@ func (s *Service) createTurn( metadata[models.TurnMetaKeyPromptDispatchClarificationMessageIDs] = recovery.MessageIDs } turn := &models.Turn{ - ID: uuid.New().String(), - TaskSessionID: sessionID, - TaskID: session.TaskID, - StartedAt: time.Now().UTC(), - Metadata: metadata, - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), + ID: uuid.New().String(), + TaskSessionID: sessionID, + TaskID: session.TaskID, + ExecutionProfileID: session.ExecutionProfileID, + RouteGeneration: session.RouteGeneration, + StartedAt: time.Now().UTC(), + Metadata: metadata, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), } stamped, err := s.turns.CreateTurnWithStepStamp(ctx, turn) @@ -275,14 +277,16 @@ func (s *Service) createCompletedTurn(ctx context.Context, session *models.TaskS } now := time.Now().UTC() turn := &models.Turn{ - ID: uuid.New().String(), - TaskSessionID: session.ID, - TaskID: session.TaskID, - StartedAt: now, - CompletedAt: &now, - Metadata: turnStartRuntimeMetadata(session), - CreatedAt: now, - UpdatedAt: now, + ID: uuid.New().String(), + TaskSessionID: session.ID, + TaskID: session.TaskID, + ExecutionProfileID: session.ExecutionProfileID, + RouteGeneration: session.RouteGeneration, + StartedAt: now, + CompletedAt: &now, + Metadata: turnStartRuntimeMetadata(session), + CreatedAt: now, + UpdatedAt: now, } stamped, err := s.turns.CreateTurnWithStepStamp(ctx, turn) if err != nil { @@ -602,13 +606,16 @@ func (s *Service) publishTurnEvent(eventType string, turn *models.Turn, hadOutpu metadata := maps.Clone(turn.Metadata) models.ClearPromptDispatchMetadata(metadata) payload := map[string]interface{}{ - "id": turn.ID, - "session_id": turn.TaskSessionID, - "task_id": turn.TaskID, - "started_at": turn.StartedAt, - "completed_at": turn.CompletedAt, - "created_at": turn.CreatedAt, - "updated_at": turn.UpdatedAt, + "id": turn.ID, + "session_id": turn.TaskSessionID, + "task_id": turn.TaskID, + "execution_profile_id": turn.ExecutionProfileID, + "route_generation": turn.RouteGeneration, + "started_at": turn.StartedAt, + "completed_at": turn.CompletedAt, + "metadata": turn.Metadata, + "created_at": turn.CreatedAt, + "updated_at": turn.UpdatedAt, } payload[turnEventMetadataKey] = metadata if hadOutput != nil { diff --git a/apps/backend/internal/utility/controller/controller.go b/apps/backend/internal/utility/controller/controller.go index ff5e0fcd69..d14db7a5a1 100644 --- a/apps/backend/internal/utility/controller/controller.go +++ b/apps/backend/internal/utility/controller/controller.go @@ -3,7 +3,10 @@ package controller import ( "context" + agentruntime "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/agent/runtime/routingerr" "github.com/kandev/kandev/internal/utility/dto" + "github.com/kandev/kandev/internal/utility/models" "github.com/kandev/kandev/internal/utility/service" "github.com/kandev/kandev/internal/utility/template" ) @@ -116,8 +119,14 @@ func (c *Controller) PreparePromptRequest(ctx context.Context, req dto.ExecutePr } // CreateCall creates a call record for tracking. -func (c *Controller) CreateCall(ctx context.Context, utilityID, sessionID, resolvedPrompt, model, profileID string) (string, error) { - call, err := c.svc.CreateCall(ctx, utilityID, sessionID, resolvedPrompt, model, profileID) +func (c *Controller) CreateCall(ctx context.Context, utilityID, sessionID, resolvedPrompt, model, profileID string, executionProfileID ...string) (string, error) { + var call *models.UtilityAgentCall + var err error + if len(executionProfileID) > 0 && executionProfileID[0] != "" { + call, err = c.svc.CreateCallWithExecutionProfile(ctx, utilityID, sessionID, resolvedPrompt, model, profileID, executionProfileID[0]) + } else { + call, err = c.svc.CreateCall(ctx, utilityID, sessionID, resolvedPrompt, model, profileID) + } if err != nil { return "", err } @@ -134,6 +143,22 @@ func (c *Controller) FailCall(ctx context.Context, callID, errorMessage string, return c.svc.FailCall(ctx, callID, errorMessage, durationMs) } +// SetCallExecutionProfile updates the concrete route attribution for a call. +func (c *Controller) SetCallExecutionProfile(ctx context.Context, callID, executionProfileID string) error { + return c.svc.SetCallExecutionProfile(ctx, callID, executionProfileID) +} + +// ResolveExecutionAfterFailure advances a shared dynamic route after a +// classified pre-result utility failure. +func (c *Controller) ResolveExecutionAfterFailure( + ctx context.Context, + sessionID, profileID, currentExecutionProfileID string, + expectedGeneration int64, + failure *routingerr.Error, +) (agentruntime.ProfileExecution, error) { + return c.svc.ResolveExecutionAfterFailure(ctx, sessionID, profileID, currentExecutionProfileID, expectedGeneration, failure) +} + // ListCalls returns the call history for a utility agent. func (c *Controller) ListCalls(ctx context.Context, utilityID string, limit int) (*dto.UtilityAgentCallsResponse, error) { calls, err := c.svc.ListCalls(ctx, utilityID, limit) diff --git a/apps/backend/internal/utility/dto/dto.go b/apps/backend/internal/utility/dto/dto.go index 28d9129515..d78e6ca1ca 100644 --- a/apps/backend/internal/utility/dto/dto.go +++ b/apps/backend/internal/utility/dto/dto.go @@ -53,20 +53,21 @@ type UpdateUtilityAgentRequest struct { // UtilityAgentCallDTO represents a call for API responses. type UtilityAgentCallDTO struct { - ID string `json:"id"` - UtilityID string `json:"utility_id"` - SessionID string `json:"session_id"` - ResolvedPrompt string `json:"resolved_prompt"` - Response string `json:"response"` - Model string `json:"model"` - AgentProfileID string `json:"agent_profile_id"` - PromptTokens int `json:"prompt_tokens"` - ResponseTokens int `json:"response_tokens"` - DurationMs int `json:"duration_ms"` - Status string `json:"status"` - ErrorMessage string `json:"error_message"` - CreatedAt string `json:"created_at"` - CompletedAt *string `json:"completed_at,omitempty"` + ID string `json:"id"` + UtilityID string `json:"utility_id"` + SessionID string `json:"session_id"` + ResolvedPrompt string `json:"resolved_prompt"` + Response string `json:"response"` + Model string `json:"model"` + AgentProfileID string `json:"agent_profile_id"` + ExecutionProfileID string `json:"execution_profile_id,omitempty"` + PromptTokens int `json:"prompt_tokens"` + ResponseTokens int `json:"response_tokens"` + DurationMs int `json:"duration_ms"` + Status string `json:"status"` + ErrorMessage string `json:"error_message"` + CreatedAt string `json:"created_at"` + CompletedAt *string `json:"completed_at,omitempty"` } // UtilityAgentCallsResponse is the response for listing calls. @@ -100,19 +101,20 @@ func FromUtilityAgent(agent *models.UtilityAgent) UtilityAgentDTO { // FromUtilityAgentCall converts a call model to a DTO. func FromUtilityAgentCall(call *models.UtilityAgentCall) UtilityAgentCallDTO { dto := UtilityAgentCallDTO{ - ID: call.ID, - UtilityID: call.UtilityID, - SessionID: call.SessionID, - ResolvedPrompt: call.ResolvedPrompt, - Response: call.Response, - Model: call.Model, - AgentProfileID: call.AgentProfileID, - PromptTokens: call.PromptTokens, - ResponseTokens: call.ResponseTokens, - DurationMs: call.DurationMs, - Status: call.Status, - ErrorMessage: call.ErrorMessage, - CreatedAt: call.CreatedAt.UTC().Format(time.RFC3339), + ID: call.ID, + UtilityID: call.UtilityID, + SessionID: call.SessionID, + ResolvedPrompt: call.ResolvedPrompt, + Response: call.Response, + Model: call.Model, + AgentProfileID: call.AgentProfileID, + ExecutionProfileID: call.ExecutionProfileID, + PromptTokens: call.PromptTokens, + ResponseTokens: call.ResponseTokens, + DurationMs: call.DurationMs, + Status: call.Status, + ErrorMessage: call.ErrorMessage, + CreatedAt: call.CreatedAt.UTC().Format(time.RFC3339), } if call.CompletedAt != nil { formatted := call.CompletedAt.UTC().Format(time.RFC3339) diff --git a/apps/backend/internal/utility/handlers/handlers.go b/apps/backend/internal/utility/handlers/handlers.go index 4481a37703..56f934d36d 100644 --- a/apps/backend/internal/utility/handlers/handlers.go +++ b/apps/backend/internal/utility/handlers/handlers.go @@ -6,12 +6,14 @@ import ( "net/http" "regexp" "strconv" + "strings" "github.com/gin-gonic/gin" "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/hostutility" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/agent/runtime/routingerr" agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/utility/controller" @@ -64,6 +66,8 @@ type Handlers struct { logger *logger.Logger } +const maxUtilityRouteAttempts = 8 + // NewHandlers creates new utility agent handlers. func NewHandlers(ctrl *controller.Controller, executor InferenceExecutor, hostExecutor HostUtilityExecutor, userSettings UserSettingsProvider, log *logger.Logger) *Handlers { return &Handlers{ @@ -232,7 +236,7 @@ func (h *Handlers) httpExecutePrompt(c *gin.Context) { } // Create call record for tracking - callID, err := h.controller.CreateCall(ctx, req.UtilityAgentID, req.SessionID, prepared.ResolvedPrompt, prepared.Model, prepared.AgentProfileID) + callID, err := h.controller.CreateCall(ctx, req.UtilityAgentID, req.SessionID, prepared.ResolvedPrompt, prepared.Model, prepared.AgentProfileID, prepared.ExecutionProfileID) if err != nil { h.logger.Error("failed to create call record", zap.Error(err)) c.JSON(http.StatusInternalServerError, dto.ExecutePromptResponse{Error: "failed to create call record"}) @@ -253,7 +257,7 @@ func (h *Handlers) httpExecutePrompt(c *gin.Context) { c.JSON(http.StatusServiceUnavailable, dto.ExecutePromptResponse{CallID: callID, Error: "profile-aware inference executor is unavailable"}) return } - resp, err = profileExecutor.ExecuteInferenceProfilePrompt(ctx, req.SessionID, prepared.AgentProfileID, prepared.ResolvedPrompt) + resp, err = h.executeSessionProfilePrompt(ctx, profileExecutor, req.SessionID, prepared, callID) } else { resp, err = h.executor.ExecuteInferencePrompt(ctx, req.SessionID, prepared.AgentCLI, prepared.Model, prepared.ResolvedPrompt) } @@ -266,6 +270,12 @@ func (h *Handlers) httpExecutePrompt(c *gin.Context) { }) return } + if resp == nil { + const message = "utility executor returned no response" + _ = h.controller.FailCall(ctx, callID, message, 0) + c.JSON(http.StatusInternalServerError, dto.ExecutePromptResponse{CallID: callID, Error: message}) + return + } if !resp.Success { _ = h.controller.FailCall(ctx, callID, resp.Error, resp.DurationMs) @@ -313,7 +323,7 @@ func (h *Handlers) executeSessionless(c *gin.Context, ctx context.Context, prepa c.JSON(http.StatusServiceUnavailable, dto.ExecutePromptResponse{CallID: callID, Error: "profile-aware host utility executor is unavailable"}) return } - result, err = profileExecutor.ExecuteProfilePrompt(ctx, prepared.AgentProfileID, prepared.ResolvedPrompt) + result, err = h.executeSessionlessProfilePrompt(ctx, profileExecutor, prepared, callID) } else { result, err = h.hostExecutor.ExecutePrompt(ctx, prepared.AgentCLI, prepared.Model, "", prepared.ResolvedPrompt) } @@ -326,6 +336,12 @@ func (h *Handlers) executeSessionless(c *gin.Context, ctx context.Context, prepa }) return } + if result == nil { + const message = "host utility returned no response" + _ = h.controller.FailCall(ctx, callID, message, 0) + c.JSON(http.StatusInternalServerError, dto.ExecutePromptResponse{CallID: callID, Error: message}) + return + } if err := h.controller.CompleteCall(ctx, callID, result.Response, result.PromptTokens, result.ResponseTokens, result.DurationMs); err != nil { h.logger.Warn("failed to update call record", zap.Error(err), zap.String("call_id", callID)) } @@ -340,6 +356,141 @@ func (h *Handlers) executeSessionless(c *gin.Context, ctx context.Context, prepa }) } +func utilityFailure(storedErr error, response string, providerID string) *routingerr.Error { + message := response + if storedErr != nil { + message = storedErr.Error() + } + return routingerr.Classify(routingerr.Input{ + Phase: routingerr.PhasePromptSend, + ProviderID: providerID, + Stderr: message, + }) +} + +func (h *Handlers) executeSessionProfilePrompt( + ctx context.Context, + executor profileInferenceExecutor, + sessionID string, + prepared *service.PromptRequest, + callID string, +) (*agentctlutil.PromptResponse, error) { + currentExecutionProfileID := prepared.ExecutionProfileID + if currentExecutionProfileID == "" { + currentExecutionProfileID = prepared.AgentProfileID + } + initialExecutionProfileID := currentExecutionProfileID + var response *agentctlutil.PromptResponse + var executeErr error + + for attempt := 0; attempt < maxUtilityRouteAttempts; attempt++ { + response, executeErr = executor.ExecuteInferenceProfilePrompt(ctx, sessionID, currentExecutionProfileID, prepared.ResolvedPrompt) + if executeErr == nil && response != nil && response.Success { + if currentExecutionProfileID != initialExecutionProfileID { + if err := h.controller.SetCallExecutionProfile(ctx, callID, currentExecutionProfileID); err != nil { + h.logger.Warn("failed to update utility route attribution", zap.Error(err), zap.String("call_id", callID)) + } + } + return response, nil + } + // A provider response with any user-visible content is already a + // result. Retrying it through another profile could repeat tool calls + // or make the caller see two incompatible answers. + if response != nil && strings.TrimSpace(response.Response) != "" { + return response, executeErr + } + + nextProfileID, advanced := h.advanceSessionProfileRoute( + ctx, prepared, currentExecutionProfileID, response, executeErr, + ) + if !advanced { + return response, executeErr + } + currentExecutionProfileID = nextProfileID + } + return response, executeErr +} + +func (h *Handlers) advanceSessionProfileRoute( + ctx context.Context, + prepared *service.PromptRequest, + currentExecutionProfileID string, + response *agentctlutil.PromptResponse, + executeErr error, +) (string, bool) { + responseError := "" + if response != nil { + responseError = response.Error + } + classified := utilityFailure(executeErr, responseError, prepared.AgentCLI) + if prepared.RouteSessionID == "" || + prepared.AgentProfileID == currentExecutionProfileID || + !classified.FallbackAllowed { + return "", false + } + next, err := h.controller.ResolveExecutionAfterFailure( + ctx, prepared.RouteSessionID, prepared.AgentProfileID, + currentExecutionProfileID, prepared.RouteGeneration, classified, + ) + if err != nil || next.ExecutionProfileID == "" { + return "", false + } + prepared.ExecutionProfileID = next.ExecutionProfileID + prepared.RouteGeneration = next.Generation + if next.Profile != nil { + prepared.AgentCLI = next.Profile.AgentID + prepared.Model = next.Profile.Model + } + return next.ExecutionProfileID, true +} + +func (h *Handlers) executeSessionlessProfilePrompt( + ctx context.Context, + executor profileHostUtilityExecutor, + prepared *service.PromptRequest, + callID string, +) (*hostutility.PromptResult, error) { + currentExecutionProfileID := prepared.ExecutionProfileID + if currentExecutionProfileID == "" { + currentExecutionProfileID = prepared.AgentProfileID + } + initialExecutionProfileID := currentExecutionProfileID + var result *hostutility.PromptResult + var executeErr error + + for attempt := 0; attempt < maxUtilityRouteAttempts; attempt++ { + result, executeErr = executor.ExecuteProfilePrompt(ctx, currentExecutionProfileID, prepared.ResolvedPrompt) + if executeErr == nil && result != nil { + if currentExecutionProfileID != initialExecutionProfileID { + if err := h.controller.SetCallExecutionProfile(ctx, callID, currentExecutionProfileID); err != nil { + h.logger.Warn("failed to update sessionless utility route attribution", zap.Error(err), zap.String("call_id", callID)) + } + } + return result, nil + } + + classified := utilityFailure(executeErr, "", prepared.AgentCLI) + if prepared.RouteSessionID == "" || !classified.FallbackAllowed { + return result, executeErr + } + next, resolveErr := h.controller.ResolveExecutionAfterFailure( + ctx, prepared.RouteSessionID, prepared.AgentProfileID, + currentExecutionProfileID, prepared.RouteGeneration, classified, + ) + if resolveErr != nil || next.ExecutionProfileID == "" { + return result, executeErr + } + currentExecutionProfileID = next.ExecutionProfileID + prepared.ExecutionProfileID = next.ExecutionProfileID + prepared.RouteGeneration = next.Generation + if next.Profile != nil { + prepared.AgentCLI = next.Profile.AgentID + prepared.Model = next.Profile.Model + } + } + return result, executeErr +} + func (h *Handlers) httpListCalls(c *gin.Context) { utilityID := c.Param("id") limit := 50 diff --git a/apps/backend/internal/utility/handlers/routing_test.go b/apps/backend/internal/utility/handlers/routing_test.go new file mode 100644 index 0000000000..36b25a6f79 --- /dev/null +++ b/apps/backend/internal/utility/handlers/routing_test.go @@ -0,0 +1,151 @@ +package handlers + +import ( + "context" + "testing" + + agentruntime "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/agent/runtime/routingerr" + agentsettingsmodels "github.com/kandev/kandev/internal/agent/settings/models" + agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/utility/controller" + "github.com/kandev/kandev/internal/utility/service" +) + +type profilePromptCall struct { + sessionID string + profileID string +} + +type profilePromptExecutor struct { + responses []*agentctlutil.PromptResponse + calls []profilePromptCall +} + +func (e *profilePromptExecutor) ExecuteInferencePrompt(context.Context, string, string, string, string) (*agentctlutil.PromptResponse, error) { + return nil, nil +} + +func (e *profilePromptExecutor) ListInferenceAgentsWithContext(context.Context) []lifecycle.InferenceAgentInfo { + return nil +} + +func (e *profilePromptExecutor) ExecuteInferenceProfilePrompt(_ context.Context, sessionID, profileID, _ string) (*agentctlutil.PromptResponse, error) { + e.calls = append(e.calls, profilePromptCall{sessionID: sessionID, profileID: profileID}) + response := e.responses[0] + e.responses = e.responses[1:] + return response, nil +} + +func TestExecuteSessionProfilePromptUsesConcreteProfileAndUpdatesAttribution(t *testing.T) { + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("NewLogger: %v", err) + } + repo := newFakeUtilityRepo() + svc := service.NewService(repo) + svc.SetExecutionProfileResolver(typedProfileFailureResolver{}) + ctrl := controller.NewController(svc) + h := NewHandlers(ctrl, nil, nil, nil, log) + call, err := svc.CreateCallWithExecutionProfile(context.Background(), "utility-1", "task-session", "prompt", "gpt-5", "logical", "first") + if err != nil { + t.Fatalf("CreateCallWithExecutionProfile: %v", err) + } + executor := &profilePromptExecutor{responses: []*agentctlutil.PromptResponse{ + {Success: false, Error: "too many requests"}, + {Success: true, Response: "done"}, + }} + prepared := &service.PromptRequest{ + AgentProfileID: "logical", + ExecutionProfileID: "first", + RouteSessionID: "route-session", + RouteGeneration: 1, + AgentCLI: "codex-acp", + ResolvedPrompt: "prompt", + } + + response, err := h.executeSessionProfilePrompt(context.Background(), executor, "task-session", prepared, call.ID) + if err != nil || response == nil || !response.Success { + t.Fatalf("executeSessionProfilePrompt() = (%#v, %v), want success", response, err) + } + if len(executor.calls) != 2 { + t.Fatalf("profile calls = %#v, want two attempts", executor.calls) + } + if executor.calls[0] != (profilePromptCall{sessionID: "task-session", profileID: "first"}) || + executor.calls[1] != (profilePromptCall{sessionID: "task-session", profileID: "second"}) { + t.Fatalf("profile calls = %#v, want first then second concrete profile", executor.calls) + } + if got := repo.call(call.ID).ExecutionProfileID; got != "second" { + t.Fatalf("execution profile attribution = %q, want second", got) + } +} + +func TestExecuteSessionProfilePromptDoesNotFallbackAfterPartialResponse(t *testing.T) { + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("NewLogger: %v", err) + } + svc := service.NewService(newFakeUtilityRepo()) + svc.SetExecutionProfileResolver(typedProfileFailureResolver{}) + h := NewHandlers(controller.NewController(svc), nil, nil, nil, log) + executor := &profilePromptExecutor{responses: []*agentctlutil.PromptResponse{ + {Success: false, Response: "partially generated answer", Error: "provider disconnected"}, + {Success: true, Response: "must not run"}, + }} + prepared := &service.PromptRequest{ + AgentProfileID: "logical", ExecutionProfileID: "first", RouteSessionID: "route-session", + RouteGeneration: 1, AgentCLI: "codex-acp", ResolvedPrompt: "prompt", + } + + response, err := h.executeSessionProfilePrompt(context.Background(), executor, "task-session", prepared, "call") + if err != nil || response == nil || response.Response != "partially generated answer" { + t.Fatalf("response = %#v, err = %v", response, err) + } + if len(executor.calls) != 1 { + t.Fatalf("profile calls = %#v, want one attempt", executor.calls) + } +} + +func TestExecuteSessionProfilePromptDoesNotRouteConcreteProfile(t *testing.T) { + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("NewLogger: %v", err) + } + svc := service.NewService(newFakeUtilityRepo()) + svc.SetExecutionProfileResolver(typedProfileFailureResolver{}) + h := NewHandlers(controller.NewController(svc), nil, nil, nil, log) + executor := &profilePromptExecutor{responses: []*agentctlutil.PromptResponse{ + {Success: false, Error: "provider disconnected"}, + {Success: true, Response: "must not run"}, + }} + prepared := &service.PromptRequest{ + AgentProfileID: "concrete", ExecutionProfileID: "concrete", RouteSessionID: "utility:call", + RouteGeneration: 1, AgentCLI: "codex-acp", ResolvedPrompt: "prompt", + } + + response, err := h.executeSessionProfilePrompt(context.Background(), executor, "task-session", prepared, "call") + if err != nil || response == nil || response.Error != "provider disconnected" { + t.Fatalf("response = %#v, err = %v", response, err) + } + if len(executor.calls) != 1 { + t.Fatalf("concrete profile calls = %#v, want one attempt", executor.calls) + } +} + +type typedProfileFailureResolver struct{} + +func (typedProfileFailureResolver) ResolveExecution(context.Context, string) (*agentsettingsmodels.AgentProfile, string, error) { + return nil, "", nil +} + +func (typedProfileFailureResolver) ResolveExecutionAfterFailure( + context.Context, string, string, string, int64, *routingerr.Error, +) (agentruntime.ProfileExecution, error) { + return agentruntime.ProfileExecution{ + ExecutionProfileID: "second", + Generation: 2, + Profile: &agentsettingsmodels.AgentProfile{ID: "second", AgentID: "claude-acp", Model: "sonnet"}, + }, nil +} diff --git a/apps/backend/internal/utility/models/models.go b/apps/backend/internal/utility/models/models.go index 5438bd8ae6..42da87ea8a 100644 --- a/apps/backend/internal/utility/models/models.go +++ b/apps/backend/internal/utility/models/models.go @@ -36,18 +36,19 @@ func UsesDefaultProfile(agent *UtilityAgent) bool { // UtilityAgentCall represents a single invocation of a utility agent. type UtilityAgentCall struct { - ID string `json:"id" db:"id"` - UtilityID string `json:"utility_id" db:"utility_id"` - SessionID string `json:"session_id" db:"session_id"` - ResolvedPrompt string `json:"resolved_prompt" db:"resolved_prompt"` - Response string `json:"response" db:"response"` - Model string `json:"model" db:"model"` - AgentProfileID string `json:"agent_profile_id" db:"agent_profile_id"` - PromptTokens int `json:"prompt_tokens" db:"prompt_tokens"` - ResponseTokens int `json:"response_tokens" db:"response_tokens"` - DurationMs int `json:"duration_ms" db:"duration_ms"` - Status string `json:"status" db:"status"` // "pending", "completed", "failed" - ErrorMessage string `json:"error_message" db:"error_message"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - CompletedAt *time.Time `json:"completed_at" db:"completed_at"` + ID string `json:"id" db:"id"` + UtilityID string `json:"utility_id" db:"utility_id"` + SessionID string `json:"session_id" db:"session_id"` + ResolvedPrompt string `json:"resolved_prompt" db:"resolved_prompt"` + Response string `json:"response" db:"response"` + Model string `json:"model" db:"model"` + AgentProfileID string `json:"agent_profile_id" db:"agent_profile_id"` + ExecutionProfileID string `json:"execution_profile_id,omitempty" db:"execution_profile_id"` + PromptTokens int `json:"prompt_tokens" db:"prompt_tokens"` + ResponseTokens int `json:"response_tokens" db:"response_tokens"` + DurationMs int `json:"duration_ms" db:"duration_ms"` + Status string `json:"status" db:"status"` // "pending", "completed", "failed" + ErrorMessage string `json:"error_message" db:"error_message"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + CompletedAt *time.Time `json:"completed_at" db:"completed_at"` } diff --git a/apps/backend/internal/utility/service/service.go b/apps/backend/internal/utility/service/service.go index 828ee9f88d..63b06c1f49 100644 --- a/apps/backend/internal/utility/service/service.go +++ b/apps/backend/internal/utility/service/service.go @@ -8,6 +8,10 @@ import ( "strings" "time" + "github.com/google/uuid" + + agentruntime "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/agent/runtime/routingerr" agentsettingsmodels "github.com/kandev/kandev/internal/agent/settings/models" "github.com/kandev/kandev/internal/utility/models" "github.com/kandev/kandev/internal/utility/profilebinding" @@ -22,6 +26,7 @@ var ( ErrBuiltinAgent = errors.New("cannot modify built-in agent") ErrProfileRequired = errors.New("utility agent profile is required") ErrProfileUnconfigured = errors.New("utility agent profile is not configured") + ErrExecutionRouting = errors.New("utility execution routing is not configured") ) type ProfileResolver interface { @@ -29,11 +34,38 @@ type ProfileResolver interface { MatchLegacy(ctx context.Context, agentID, model string) (*agentsettingsmodels.AgentProfile, error) } +// ExecutionProfileResolver is an optional shared-router extension. It keeps +// the logical profile ID on the call while returning the concrete profile used +// for this one utility invocation. +type ExecutionProfileResolver interface { + ResolveExecution(context.Context, string) (*agentsettingsmodels.AgentProfile, string, error) +} + +// SessionExecutionProfileResolver is the session-aware form used by dynamic +// routing. The legacy interface remains supported for small callers and test +// doubles that only need a concrete profile lookup. +type SessionExecutionProfileResolver interface { + ResolveExecutionForSession(context.Context, string, string) (*agentsettingsmodels.AgentProfile, string, error) +} + +// ExecutionDetailsResolver exposes route identity to callers that may need to +// apply a classified pre-result failure to the same dynamic route. +type ExecutionDetailsResolver interface { + ResolveExecutionDetails(context.Context, string, string) (agentruntime.ProfileExecution, error) +} + +// ExecutionFailureResolver advances a dynamic route after a classified +// failure that occurred before any user-visible result was produced. +type ExecutionFailureResolver interface { + ResolveExecutionAfterFailure(context.Context, string, string, string, int64, *routingerr.Error) (agentruntime.ProfileExecution, error) +} + // Service provides business logic for utility agents. type Service struct { - repo store.Repository - templateEngine *template.Engine - profileResolver ProfileResolver + repo store.Repository + templateEngine *template.Engine + profileResolver ProfileResolver + executionResolver ExecutionProfileResolver } // SetProfileResolver wires the operator-owned profile eligibility boundary. @@ -41,6 +73,25 @@ func (s *Service) SetProfileResolver(resolver ProfileResolver) { s.profileResolver = resolver } +func (s *Service) SetExecutionProfileResolver(resolver ExecutionProfileResolver) { + s.executionResolver = resolver +} + +// ResolveExecutionAfterFailure advances a shared dynamic route after a +// classified pre-result utility failure. +func (s *Service) ResolveExecutionAfterFailure( + ctx context.Context, + sessionID, profileID, currentExecutionProfileID string, + expectedGeneration int64, + failure *routingerr.Error, +) (agentruntime.ProfileExecution, error) { + resolver, ok := s.executionResolver.(ExecutionFailureResolver) + if !ok { + return agentruntime.ProfileExecution{}, ErrExecutionRouting + } + return resolver.ResolveExecutionAfterFailure(ctx, sessionID, profileID, currentExecutionProfileID, expectedGeneration, failure) +} + // NewService creates a new utility agents service. func NewService(repo store.Repository) *Service { return &Service{ @@ -345,12 +396,51 @@ func (s *Service) PreparePromptRequest(ctx context.Context, utilityID string, tm if err != nil { return nil, err } + logicalProfileID := profile.ID + executionProfileID := logicalProfileID + routeSessionID := "" + routeGeneration := int64(0) + if s.executionResolver != nil { + var resolved *agentsettingsmodels.AgentProfile + var concreteID string + var resolveErr error + if detailsResolver, ok := s.executionResolver.(ExecutionDetailsResolver); ok { + routeSessionID = "utility:" + uuid.NewString() + details, detailsErr := detailsResolver.ResolveExecutionDetails(ctx, routeSessionID, profileID) + if detailsErr != nil { + return nil, detailsErr + } + resolved = details.Profile + concreteID = details.ExecutionProfileID + if details.RouteSessionID != "" { + routeSessionID = details.RouteSessionID + } + routeGeneration = details.Generation + } else if sessionResolver, ok := s.executionResolver.(SessionExecutionProfileResolver); ok { + routeSessionID = "utility:" + uuid.NewString() + resolved, concreteID, resolveErr = sessionResolver.ResolveExecutionForSession(ctx, routeSessionID, profileID) + } else { + resolved, concreteID, resolveErr = s.executionResolver.ResolveExecution(ctx, profileID) + } + if resolveErr != nil { + return nil, resolveErr + } + if resolved != nil { + profile = resolved + } + if concreteID != "" { + executionProfileID = concreteID + } + } return &PromptRequest{ - UtilityID: utilityID, - ResolvedPrompt: resolvedPrompt, - AgentCLI: profile.AgentID, - Model: profile.Model, - AgentProfileID: profile.ID, + UtilityID: utilityID, + ResolvedPrompt: resolvedPrompt, + AgentCLI: profile.AgentID, + Model: profile.Model, + AgentProfileID: logicalProfileID, + ExecutionProfileID: executionProfileID, + RouteSessionID: routeSessionID, + RouteGeneration: routeGeneration, }, nil } @@ -378,11 +468,14 @@ func (s *Service) PreparePromptRequest(ctx context.Context, utilityID string, tm // PromptRequest contains the prepared request for executing a utility prompt. type PromptRequest struct { - UtilityID string - ResolvedPrompt string - AgentCLI string // The inference agent ID (e.g., "claude-acp", "amp") - Model string // The model to use - AgentProfileID string + UtilityID string + ResolvedPrompt string + AgentCLI string // The inference agent ID (e.g., "claude-acp", "amp") + Model string // The model to use + AgentProfileID string + ExecutionProfileID string + RouteSessionID string + RouteGeneration int64 } // CreateCall creates a new call record (for tracking history). @@ -404,6 +497,29 @@ func (s *Service) CreateCall(ctx context.Context, utilityID, sessionID, resolved return call, nil } +func (s *Service) CreateCallWithExecutionProfile(ctx context.Context, utilityID, sessionID, resolvedPrompt, model, logicalProfileID, executionProfileID string) (*models.UtilityAgentCall, error) { + call, err := s.CreateCall(ctx, utilityID, sessionID, resolvedPrompt, model, logicalProfileID) + if err != nil { + return nil, err + } + call.ExecutionProfileID = executionProfileID + if err := s.repo.UpdateCall(ctx, call); err != nil { + return nil, err + } + return call, nil +} + +// SetCallExecutionProfile updates the concrete profile attribution after a +// pre-result route fallback succeeds. +func (s *Service) SetCallExecutionProfile(ctx context.Context, callID, executionProfileID string) error { + call, err := s.repo.GetCallByID(ctx, callID) + if err != nil { + return ErrCallNotFound + } + call.ExecutionProfileID = executionProfileID + return s.repo.UpdateCall(ctx, call) +} + // CompleteCall marks a call as completed with the response. func (s *Service) CompleteCall(ctx context.Context, callID, response string, promptTokens, responseTokens, durationMs int) error { call, err := s.repo.GetCallByID(ctx, callID) diff --git a/apps/backend/internal/utility/service/service_test.go b/apps/backend/internal/utility/service/service_test.go index 8a0f2deea2..f4c4bd4bf2 100644 --- a/apps/backend/internal/utility/service/service_test.go +++ b/apps/backend/internal/utility/service/service_test.go @@ -6,6 +6,7 @@ import ( "errors" "testing" + agentruntime "github.com/kandev/kandev/internal/agent/runtime" agentsettingsmodels "github.com/kandev/kandev/internal/agent/settings/models" "github.com/kandev/kandev/internal/utility/models" "github.com/kandev/kandev/internal/utility/profilebinding" @@ -43,6 +44,50 @@ func TestMigrateLegacyBindingsUpdatesOnlyUnambiguousRows(t *testing.T) { } } +func TestPreparePromptRequestUsesFreshUtilityRouteForTaskBoundCall(t *testing.T) { + repo := &fakeRepository{agents: map[string]*models.UtilityAgent{ + "utility": {ID: "utility", AgentProfileID: "dynamic", ProfileBindingState: models.ProfileBindingExplicit, Prompt: "summarize"}, + }} + resolver := &recordingExecutionDetailsResolver{} + svc := NewService(repo) + svc.SetProfileResolver(fakeProfileResolver{profile: &agentsettingsmodels.AgentProfile{ID: "dynamic", AgentID: "dynamic"}}) + svc.SetExecutionProfileResolver(resolver) + + ctx := &template.Context{SessionID: "task-session"} + first, err := svc.PreparePromptRequest(context.Background(), "utility", ctx, nil, false) + if err != nil { + t.Fatalf("first PreparePromptRequest: %v", err) + } + second, err := svc.PreparePromptRequest(context.Background(), "utility", ctx, nil, false) + if err != nil { + t.Fatalf("second PreparePromptRequest: %v", err) + } + if first.RouteSessionID == "" || second.RouteSessionID == "" || first.RouteSessionID == second.RouteSessionID { + t.Fatalf("route IDs = %q and %q, want distinct utility invocations", first.RouteSessionID, second.RouteSessionID) + } + if len("utility:") > len(first.RouteSessionID) || first.RouteSessionID[:len("utility:")] != "utility:" || + len("utility:") > len(second.RouteSessionID) || second.RouteSessionID[:len("utility:")] != "utility:" { + t.Fatalf("route IDs = %q and %q, want utility prefix", first.RouteSessionID, second.RouteSessionID) + } + if resolver.sessions[0] == "task-session" || resolver.sessions[1] == "task-session" { + t.Fatalf("resolver received task session identity: %#v", resolver.sessions) + } +} + +type recordingExecutionDetailsResolver struct{ sessions []string } + +func (r *recordingExecutionDetailsResolver) ResolveExecution(context.Context, string) (*agentsettingsmodels.AgentProfile, string, error) { + return nil, "", nil +} + +func (r *recordingExecutionDetailsResolver) ResolveExecutionDetails(_ context.Context, sessionID, _ string) (agentruntime.ProfileExecution, error) { + r.sessions = append(r.sessions, sessionID) + return agentruntime.ProfileExecution{ + LogicalProfileID: "dynamic", ExecutionProfileID: "concrete", RouteSessionID: sessionID, + Generation: 1, Profile: &agentsettingsmodels.AgentProfile{ID: "concrete", AgentID: "claude-acp"}, + }, nil +} + func TestMigrateLegacyBindingsNormalizesEmptyUnconfiguredBuiltin(t *testing.T) { repo := &fakeRepository{agents: map[string]*models.UtilityAgent{ "builtin": { diff --git a/apps/backend/internal/utility/store/sqlite.go b/apps/backend/internal/utility/store/sqlite.go index 6411a491f8..356a94baac 100644 --- a/apps/backend/internal/utility/store/sqlite.go +++ b/apps/backend/internal/utility/store/sqlite.go @@ -65,6 +65,7 @@ func (r *sqliteRepository) initSchema() error { response TEXT NOT NULL DEFAULT '', model TEXT NOT NULL DEFAULT '', agent_profile_id TEXT NOT NULL DEFAULT '', + execution_profile_id TEXT NOT NULL DEFAULT '', prompt_tokens INTEGER NOT NULL DEFAULT 0, response_tokens INTEGER NOT NULL DEFAULT 0, duration_ms INTEGER NOT NULL DEFAULT 0, @@ -88,6 +89,7 @@ func (r *sqliteRepository) initSchema() error { _, _ = r.db.Exec(`ALTER TABLE utility_agents ADD COLUMN agent_profile_id TEXT NOT NULL DEFAULT ''`) _, _ = r.db.Exec(`ALTER TABLE utility_agents ADD COLUMN profile_binding_state TEXT NOT NULL DEFAULT 'explicit'`) _, _ = r.db.Exec(`ALTER TABLE utility_agent_calls ADD COLUMN agent_profile_id TEXT NOT NULL DEFAULT ''`) + _, _ = r.db.Exec(`ALTER TABLE utility_agent_calls ADD COLUMN execution_profile_id TEXT NOT NULL DEFAULT ''`) // Heal pre-existing custom agents that were created with enabled=0 due // to a bug in CreateAgent (the Enabled field on the model defaulted to @@ -303,7 +305,7 @@ func (r *sqliteRepository) ListCalls(ctx context.Context, utilityID string, limi limit = 50 } rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` - SELECT id, utility_id, session_id, resolved_prompt, response, model, agent_profile_id, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at + SELECT id, utility_id, session_id, resolved_prompt, response, model, agent_profile_id, execution_profile_id, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at FROM utility_agent_calls WHERE utility_id = ? ORDER BY created_at DESC @@ -323,7 +325,7 @@ func (r *sqliteRepository) scanCallRows(rows *sql.Rows) ([]*models.UtilityAgentC call := &models.UtilityAgentCall{} if err := rows.Scan( &call.ID, &call.UtilityID, &call.SessionID, &call.ResolvedPrompt, &call.Response, - &call.Model, &call.AgentProfileID, &call.PromptTokens, &call.ResponseTokens, &call.DurationMs, + &call.Model, &call.AgentProfileID, &call.ExecutionProfileID, &call.PromptTokens, &call.ResponseTokens, &call.DurationMs, &call.Status, &call.ErrorMessage, &call.CreatedAt, &call.CompletedAt, ); err != nil { return nil, err @@ -335,13 +337,13 @@ func (r *sqliteRepository) scanCallRows(rows *sql.Rows) ([]*models.UtilityAgentC func (r *sqliteRepository) GetCallByID(ctx context.Context, id string) (*models.UtilityAgentCall, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, utility_id, session_id, resolved_prompt, response, model, agent_profile_id, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at + SELECT id, utility_id, session_id, resolved_prompt, response, model, agent_profile_id, execution_profile_id, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at FROM utility_agent_calls WHERE id = ? `), id) call := &models.UtilityAgentCall{} if err := row.Scan( &call.ID, &call.UtilityID, &call.SessionID, &call.ResolvedPrompt, &call.Response, - &call.Model, &call.AgentProfileID, &call.PromptTokens, &call.ResponseTokens, &call.DurationMs, + &call.Model, &call.AgentProfileID, &call.ExecutionProfileID, &call.PromptTokens, &call.ResponseTokens, &call.DurationMs, &call.Status, &call.ErrorMessage, &call.CreatedAt, &call.CompletedAt, ); err != nil { return nil, err @@ -361,10 +363,10 @@ func (r *sqliteRepository) CreateCall(ctx context.Context, call *models.UtilityA } _, err := r.db.ExecContext(ctx, r.db.Rebind(` - INSERT INTO utility_agent_calls (id, utility_id, session_id, resolved_prompt, response, model, agent_profile_id, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO utility_agent_calls (id, utility_id, session_id, resolved_prompt, response, model, agent_profile_id, execution_profile_id, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), call.ID, call.UtilityID, call.SessionID, call.ResolvedPrompt, call.Response, - call.Model, call.AgentProfileID, call.PromptTokens, call.ResponseTokens, call.DurationMs, + call.Model, call.AgentProfileID, call.ExecutionProfileID, call.PromptTokens, call.ResponseTokens, call.DurationMs, call.Status, call.ErrorMessage, call.CreatedAt, call.CompletedAt) return err } @@ -375,9 +377,9 @@ func (r *sqliteRepository) UpdateCall(ctx context.Context, call *models.UtilityA } _, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE utility_agent_calls - SET response = ?, model = ?, agent_profile_id = ?, prompt_tokens = ?, response_tokens = ?, duration_ms = ?, status = ?, error_message = ?, completed_at = ? + SET response = ?, model = ?, agent_profile_id = ?, execution_profile_id = ?, prompt_tokens = ?, response_tokens = ?, duration_ms = ?, status = ?, error_message = ?, completed_at = ? WHERE id = ? - `), call.Response, call.Model, call.AgentProfileID, call.PromptTokens, call.ResponseTokens, call.DurationMs, + `), call.Response, call.Model, call.AgentProfileID, call.ExecutionProfileID, call.PromptTokens, call.ResponseTokens, call.DurationMs, call.Status, call.ErrorMessage, call.CompletedAt, call.ID) return err } diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 452e127a61..2a9b4c91cf 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -96,6 +96,7 @@ const ( ActionSessionSetPrimary = "session.set_primary" ActionSessionSetPlanMode = "session.set_plan_mode" ActionSessionRename = "session.rename" + ActionSessionRouteAction = "session.route_action" // Agent actions ActionAgentList = "agent.list" @@ -238,6 +239,11 @@ const ( ActionSessionTodosUpdated = "session.todos_updated" ActionSessionPromptUsage = "session.prompt_usage" ActionSessionPollModeChanged = "session.poll_mode_changed" + ActionSessionRouteChanging = "session.route_changing" + ActionSessionRouteChanged = "session.route_changed" + ActionSessionCapabilitiesReplaced = "session.capabilities_replaced" + ActionSessionRouteWaiting = "session.route_waiting" + ActionSessionRoutePending = "session.route_pending" ActionInputRequested = "input.requested" ActionRepositoryCreated = "repository.created" ActionRepositoryUpdated = "repository.updated" diff --git a/apps/web/app/actions/agents.ts b/apps/web/app/actions/agents.ts index ddd127f49e..5b97721ce4 100644 --- a/apps/web/app/actions/agents.ts +++ b/apps/web/app/actions/agents.ts @@ -15,12 +15,23 @@ import type { } from "@/lib/types/http"; import type { PermissionKey } from "@/lib/agent-permissions"; import { normalizeAgentProfile } from "@/lib/api/domains/agent-profile-normalize"; +import type { AgentProfileKind } from "@/lib/types/agent-profile"; type ProfilePermissions = Record; const { apiBaseUrl } = getBackendConfig(); const interimSettingsInterlockHeader = "X-Kandev-Interim-Settings-Interlock"; +type DynamicProfilePayload = { + version: number; + candidates: Array<{ + position: number; + execution_profile_id: string; + enabled: boolean; + rules?: Record; + }>; +}; + function normalizeAgentInPlace(agent: Agent): Agent { return { ...agent, @@ -56,11 +67,13 @@ export async function createAgentAction(payload: { { name: string; model: string; + kind?: AgentProfileKind; mode?: string; cli_passthrough: boolean; cli_flags?: CLIFlag[]; command_prefix?: string; env_vars?: ProfileEnvVar[]; + dynamic?: DynamicProfilePayload; } & ProfilePermissions >; }): Promise { @@ -95,6 +108,7 @@ export async function createAgentProfileAction( payload: { name: string; model: string; + kind?: AgentProfileKind; fallback_model?: string; auto_fallback?: boolean; mode?: string; @@ -103,6 +117,7 @@ export async function createAgentProfileAction( cli_flags?: CLIFlag[]; command_prefix?: string; env_vars?: ProfileEnvVar[]; + dynamic?: DynamicProfilePayload; } & ProfilePermissions, ): Promise { const raw = await agentSettingsRequest( @@ -120,6 +135,7 @@ export async function updateAgentProfileAction( payload: { name?: string; model?: string; + kind?: AgentProfileKind; fallback_model?: string; auto_fallback?: boolean; mode?: string; @@ -131,6 +147,7 @@ export async function updateAgentProfileAction( cli_flags?: CLIFlag[]; command_prefix?: string; env_vars?: ProfileEnvVar[]; + dynamic?: DynamicProfilePayload; }, force = false, ): Promise { diff --git a/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts index d6ea3bf9e4..8882b89055 100644 --- a/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts +++ b/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts @@ -18,6 +18,7 @@ import { arePermissionsDirty, permissionsToProfilePatch } from "@/lib/agent-perm import { areCLIFlagsEqual } from "@/lib/cli-flags"; import { areConfigOptionsEqual } from "@/lib/config-options"; import { t } from "@/lib/i18n"; +import { toAgentProfilePayload } from "@/lib/api/domains/agent-profile-normalize"; import type { ProfileFormData } from "@/components/settings/profile-form-fields"; // The JSON key the MCP editor validates against — an identifier, interpolated @@ -85,6 +86,11 @@ export type DraftProfile = AgentProfile & { export type DraftAgent = Omit & { profiles: DraftProfile[]; isNew?: boolean }; +function dynamicProfilePayload(profile: DraftProfile) { + if (profile.kind !== "dynamic" && !profile.dynamic) return undefined; + return toAgentProfilePayload(profile).dynamic; +} + export const parseProfileMcpServers = (raw: string): Record => { if (!raw.trim()) return {}; const parsed = JSON.parse(raw) as unknown; @@ -212,6 +218,7 @@ export async function saveNewAgent(draftAgent: DraftAgent, callbacks: SaveAgentC profiles: draftAgent.profiles.map((profile) => ({ name: profile.name, model: profile.model, + kind: profile.kind, fallback_model: profile.fallbackModel ?? "", auto_fallback: profile.autoFallback ?? false, mode: profile.mode, @@ -221,6 +228,7 @@ export async function saveNewAgent(draftAgent: DraftAgent, callbacks: SaveAgentC cli_flags: profile.cliFlags ?? [], command_prefix: profile.commandPrefix ?? "", env_vars: profile.envVars ?? [], + dynamic: dynamicProfilePayload(profile), })), }); @@ -290,6 +298,7 @@ async function savePersistedProfile( return updateAgentProfileAction(profile.id, { name: profile.name, model: profile.model, + kind: profile.kind, fallback_model: profile.fallbackModel ?? "", auto_fallback: profile.autoFallback ?? false, mode: profile.mode, @@ -299,6 +308,7 @@ async function savePersistedProfile( cli_flags: profile.cliFlags ?? [], command_prefix: profile.commandPrefix ?? "", env_vars: profile.envVars ?? [], + dynamic: dynamicProfilePayload(profile), }); } const { mcp_config: _pendingMcp, ...persistedProfile } = savedProfile as DraftProfile; @@ -324,6 +334,7 @@ async function saveExistingProfiles( const createdProfile = await createAgentProfileAction(savedAgent.id, { name: profile.name, model: profile.model, + kind: profile.kind, fallback_model: profile.fallbackModel ?? "", auto_fallback: profile.autoFallback ?? false, mode: profile.mode, @@ -333,6 +344,7 @@ async function saveExistingProfiles( cli_flags: profile.cliFlags ?? [], command_prefix: profile.commandPrefix ?? "", env_vars: profile.envVars ?? [], + dynamic: dynamicProfilePayload(profile), }); profileIds.set(profile.id, createdProfile.id); persistedSubmittedIds.add(profile.id); @@ -507,16 +519,30 @@ function isProfileCliConfigDirty(draft: DraftProfile, saved: AgentProfile): bool ); } +function isProfileIdentityDirty(draft: DraftProfile, saved: AgentProfile): boolean { + return [ + (draft.kind ?? "concrete") !== (saved.kind ?? "concrete"), + JSON.stringify(draft.dynamic ?? null) !== JSON.stringify(saved.dynamic ?? null), + draft.name !== saved.name, + draft.model !== saved.model, + (draft.mode ?? "") !== (saved.mode ?? ""), + (draft.fallbackModel ?? "") !== (saved.fallbackModel ?? ""), + (draft.autoFallback ?? false) !== (saved.autoFallback ?? false), + ].some(Boolean); +} + +function isProfileSettingsDirty(draft: DraftProfile, saved: AgentProfile): boolean { + return ( + areConfigOptionsEqual(draft.configOptions, saved.configOptions) === false || + arePermissionsDirty(draft, saved) + ); +} + export function isProfileDirty(draft: DraftProfile, saved?: AgentProfile): boolean { if (!saved) return true; return ( - draft.name !== saved.name || - draft.model !== saved.model || - (draft.mode ?? "") !== (saved.mode ?? "") || - (draft.fallbackModel ?? "") !== (saved.fallbackModel ?? "") || - (draft.autoFallback ?? false) !== (saved.autoFallback ?? false) || - !areConfigOptionsEqual(draft.configOptions, saved.configOptions) || - arePermissionsDirty(draft, saved) || + isProfileIdentityDirty(draft, saved) || + isProfileSettingsDirty(draft, saved) || isProfileCliConfigDirty(draft, saved) ); } diff --git a/apps/web/app/settings/agents/[agentId]/agent-setup-parts.tsx b/apps/web/app/settings/agents/[agentId]/agent-setup-parts.tsx index 6d4c347a7d..d2e32c459e 100644 --- a/apps/web/app/settings/agents/[agentId]/agent-setup-parts.tsx +++ b/apps/web/app/settings/agents/[agentId]/agent-setup-parts.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"; import { IconPlus, IconTrash } from "@tabler/icons-react"; +import Link from "@/components/routing/app-link"; import { AlertDialog, AlertDialogAction, @@ -25,6 +26,7 @@ import { CustomCLIFlagsCard } from "@/components/settings/cli-flags-field"; import type { Agent, ModelConfig, PermissionSetting, PassthroughConfig } from "@/lib/types/http"; import { ProfileMcpConfigCard } from "./profile-mcp-config-card"; import { profilePermissionValues } from "@/lib/agent-permissions"; +import { DynamicAgentProfileEditor } from "@/components/settings/dynamic-agent-profile-editor"; import { isProfileDirty, toAgentProfilePatch, @@ -55,6 +57,7 @@ export type AgentHeaderProps = { matchedPath: string | null | undefined; isCreateMode: boolean; savedAgent: Agent | null; + showInstallationStatus?: boolean; onDelete?: () => void; }; @@ -63,6 +66,7 @@ export function AgentHeader({ matchedPath, isCreateMode, savedAgent, + showInstallationStatus = true, onDelete, }: AgentHeaderProps) { const { t } = useTranslation(); @@ -71,14 +75,16 @@ export function AgentHeader({ title={ {displayName} - - {matchedPath ?? t("agents:installationNotDetected")} - + {showInstallationStatus && ( + + {matchedPath ?? t("agents:installationNotDetected")} + + )} } description={ @@ -222,6 +228,29 @@ export type ProfilesCardProps = { onToastError: (error: unknown) => void; }; +function dynamicProfileRoute(agent: Agent, profile: DraftProfile): string { + return `/settings/agents/${encodeURIComponent(agent.name)}/profiles/${encodeURIComponent(profile.id)}`; +} + +function DynamicProfileRouteRow({ agent, profile }: { agent: Agent; profile: DraftProfile }) { + const { t } = useTranslation(); + const candidateCount = profile.dynamic?.candidates.length ?? 0; + return ( + + + {profile.name || t("agents:profileName")} + + + {t("agents:dynamicCandidates")}: {candidateCount} + + + ); +} + export function ProfilesCard({ displayName, isCreateMode, @@ -239,6 +268,49 @@ export function ProfilesCard({ onToastError, }: ProfilesCardProps) { const { t } = useTranslation(); + const dynamicProfiles = draftAgent.profiles.filter( + (profile) => profile.kind === "dynamic" || draftAgent.name === "dynamic", + ); + + if (dynamicProfiles.length > 0) { + return ( + + + + {t("agents:addProfile")} + + ) + } + /> + + {dynamicProfiles.map((profile) => ( +
+ {profile.isNew ? ( + onProfileChange(profile.id, patch)} + /> + ) : ( + + )} +
+ ))} +
+
+ ); + } + return ( , + kind: AgentProfileKind = "concrete", ): DraftProfile => ({ id: toAgentProfileId(`draft-${generateUUID()}`), + kind, + dynamic: kind === "dynamic" ? { version: 1, candidates: [] } : undefined, agentId, name: "", agentDisplayName, @@ -80,7 +84,15 @@ const ensureProfiles = ( if (agent.profiles.length > 0) return agent; return { ...agent, - profiles: [createDraftProfile(agent.id, agentDisplayName, defaultModel, permissionSettings)], + profiles: [ + createDraftProfile( + agent.id, + agentDisplayName, + defaultModel, + permissionSettings, + agent.name === "dynamic" ? "dynamic" : "concrete", + ), + ], }; }; @@ -210,7 +222,7 @@ function useAgentSaveHandlers({ onToastError(new Error(t("agents:profileNameRequired"))); return; } - if (draftAgent.profiles.some((p) => !p.model.trim())) { + if (draftAgent.profiles.some((p) => p.kind !== "dynamic" && !p.model.trim())) { onToastError(new Error(t("agents:modelRequiredForAllProfiles"))); return; } @@ -279,6 +291,7 @@ function useProfileHandlers( resolveDisplayName(current.name), defaultModel, permissionSettings, + current.name === "dynamic" ? "dynamic" : "concrete", ), id: draftId, }, @@ -301,6 +314,7 @@ function useProfileHandlers( resolveDisplayName(current.name), defaultModel, permissionSettings, + current.name === "dynamic" ? "dynamic" : "concrete", ), ], }; @@ -348,7 +362,11 @@ function useProfileHandlers( } function areAgentProfilesValid(agent: DraftAgent): boolean { - return agent.profiles.every((profile) => profile.name.trim() && profile.model.trim()); + return agent.profiles.every((profile) => { + if (!profile.name.trim()) return false; + if (profile.kind === "dynamic") return (profile.dynamic?.candidates.length ?? 0) > 0; + return profile.model.trim().length > 0; + }); } function useAgentSaveRevision(agent: DraftAgent) { @@ -366,8 +384,11 @@ function resolveSaveInvalidReason( t: (key: string) => string, profilesValid: boolean, hasInvalidMcpConfig: boolean, + dynamic: boolean, ): string | undefined { - if (!profilesValid) return t("agents:everyProfileNeedsNameAndModel"); + if (!profilesValid) { + return t(dynamic ? "agents:noDynamicCandidates" : "agents:everyProfileNeedsNameAndModel"); + } if (hasInvalidMcpConfig) return t("agents:fixInvalidMcpConfig"); return undefined; } @@ -429,7 +450,12 @@ function AgentSetupForm({ if (savedDraft) saveRevision.setSaved(JSON.stringify(savedDraft)); }; const profilesValid = areAgentProfilesValid(draftAgent); - const saveInvalidReason = resolveSaveInvalidReason(t, profilesValid, hasInvalidMcpConfig); + const saveInvalidReason = resolveSaveInvalidReason( + t, + profilesValid, + hasInvalidMcpConfig, + draftAgent.name === "dynamic", + ); useSettingsSaveContributor({ id: `agent:${draftAgent.id}`, revision: saveRevision.revision, @@ -449,6 +475,7 @@ function AgentSetupForm({ matchedPath={discoveryAgent?.matched_path} isCreateMode={isCreateMode} savedAgent={savedAgent} + showInstallationStatus={draftAgent.name !== "dynamic"} onDelete={handleDeleteAgent} /> diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx index 22916ab055..be51bab8e8 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -31,9 +31,11 @@ import { AgentProfilesSubList } from "@/components/settings/agents/agent-profile import { HostShellDialog } from "@/components/settings/host-shell-dialog"; import { CustomTUIMcpCard } from "@/components/settings/custom-tui-mcp-card"; import { InstalledAgentCard } from "@/components/settings/installed-agent-card"; +import { DynamicAgentsCard } from "@/components/settings/dynamic-agents-card"; import { AGENTS_BROWSE_SETTINGS_HREF } from "@/lib/settings-discovery/catalog/agents"; import { detectedAgents, + DYNAMIC_AGENT_NAME, orderAgentsForDisplay, orphanedAgents, type DiscoveredAgent, @@ -137,18 +139,20 @@ function agentCards( agent, detected: true, })); - const orphans: AgentCard[] = orphanedAgents(installedAgents, savedAgents).map((agent) => ({ - key: agent.id, - agent: { - name: agent.name, - supports_mcp: agent.supports_mcp, - mcp_config_path: agent.mcp_config_path ?? null, - installation_paths: [], - available: false, - matched_path: null, - }, - detected: false, - })); + const orphans: AgentCard[] = orphanedAgents(installedAgents, savedAgents) + .filter((agent) => agent.name !== DYNAMIC_AGENT_NAME) + .map((agent) => ({ + key: agent.id, + agent: { + name: agent.name, + supports_mcp: agent.supports_mcp, + mcp_config_path: agent.mcp_config_path ?? null, + installation_paths: [], + available: false, + matched_path: null, + }, + detected: false, + })); // Rank by name against the same list the menu ranks against, so the two // surfaces cannot order the same agents differently. const ordered = orderAgentsForDisplay( @@ -185,6 +189,7 @@ function InstalledAgentsSection({ // synthetic discovery record, so their profiles never vanish; they just keep // their rank. The settings menu ranks the same way — see `agent-display-order`. const cards = agentCards(installedAgents, savedAgents, discoveryOrder); + const dynamicAgent = savedAgentsByName.get(DYNAMIC_AGENT_NAME); return (
@@ -204,6 +209,8 @@ function InstalledAgentsSection({ }} /> + + {cards.length === 0 && ( diff --git a/apps/web/app/settings/workspace/workspace-edit-client.tsx b/apps/web/app/settings/workspace/workspace-edit-client.tsx index 17f100f37d..a079f3a048 100644 --- a/apps/web/app/settings/workspace/workspace-edit-client.tsx +++ b/apps/web/app/settings/workspace/workspace-edit-client.tsx @@ -24,6 +24,7 @@ import { updateWorkspaceAction, deleteWorkspaceAction } from "@/app/actions/work import type { TFunction } from "i18next"; import type { Executor } from "@/lib/types/http"; import type { AgentProfileOption, WorkspaceState } from "@/lib/state/slices"; +import { isSelectableAgentProfile } from "@/lib/state/slices/settings/types"; type Workspace = WorkspaceState["items"][number]; import { useRequest } from "@/lib/http/use-request"; @@ -153,11 +154,14 @@ function WorkspaceSettingsCard({ agentProfiles, }: WorkspaceSettingsCardProps) { const { t } = useTranslation(); + const dynamicRoutingEnabled = useFeature("dynamicAgentRouting"); const executorOptions = activeExecutors.map((e: Executor) => ({ id: e.id, name: e.name })); - const profileOptions = agentProfiles.map((p: AgentProfileOption) => ({ - id: p.id, - name: p.label, - })); + const profileOptions = agentProfiles + .filter((profile) => isSelectableAgentProfile(profile, dynamicRoutingEnabled)) + .map((p: AgentProfileOption) => ({ + id: p.id, + name: p.label, + })); return ( diff --git a/apps/web/components/automations/config-section.test.tsx b/apps/web/components/automations/config-section.test.tsx index 6b827c4706..9a26a00546 100644 --- a/apps/web/components/automations/config-section.test.tsx +++ b/apps/web/components/automations/config-section.test.tsx @@ -22,6 +22,7 @@ const REPOSITORY_SELECTOR_TEST_ID = "repository-selector"; const REPOSITORY_ROWS_TEST_ID = "repository-rows"; const mockState = { + features: { dynamicAgentRouting: true }, workflows: { items: [LOCAL_WORKFLOW] as MockWorkflow[], }, diff --git a/apps/web/components/automations/config-section.tsx b/apps/web/components/automations/config-section.tsx index 7dc5f80eb8..304ba25988 100644 --- a/apps/web/components/automations/config-section.tsx +++ b/apps/web/components/automations/config-section.tsx @@ -8,6 +8,7 @@ import { SettingsFieldLabel } from "@/components/settings/settings-typography"; import { settingsControlClassName } from "@/components/settings/settings-control"; import { useAppStore } from "@/components/state-provider"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; +import { useFeature } from "@/hooks/domains/features/use-feature"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; import { discoverRepositoriesAction } from "@/app/actions/workspaces"; import { listWorkflows } from "@/lib/api"; @@ -203,7 +204,12 @@ function useWorkflowSteps(workflowId: string) { return steps; } -type AgentProfileLike = { id: string; label: string; cli_passthrough?: boolean }; +type AgentProfileLike = { + id: string; + label: string; + cli_passthrough?: boolean; + kind?: "concrete" | "dynamic"; +}; type ExecutorLike = { type: string; name: string; profiles?: ExecutorProfile[] }; // useConfigSectionComputed derives the Agent Profile / Executor Profile / @@ -226,7 +232,10 @@ function useConfigSectionComputed({ discoveredRepos: LocalRepository[]; }) { const { t } = useTranslation(); - const filteredAgentProfiles = agentProfiles.filter((profile) => !profile.cli_passthrough); + const dynamicRoutingEnabled = useFeature("dynamicAgentRouting"); + const filteredAgentProfiles = agentProfiles.filter( + (profile) => !profile.cli_passthrough && (dynamicRoutingEnabled || profile.kind !== "dynamic"), + ); // Profiles returned by the executors list/boot payload don't always carry // their own executor_type/executor_name (only the settings > Executors // page's local mapper attaches those today) — fall back to the parent diff --git a/apps/web/components/config-chat/config-chat-setup.test.tsx b/apps/web/components/config-chat/config-chat-setup.test.tsx index 3c0cac49cc..dbf3294156 100644 --- a/apps/web/components/config-chat/config-chat-setup.test.tsx +++ b/apps/web/components/config-chat/config-chat-setup.test.tsx @@ -11,7 +11,7 @@ const configPromptPlaceholder = "Ask anything about your configuration..."; vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: unknown) => unknown) => - selector({ agentProfiles: { items: profiles } }), + selector({ features: { dynamicAgentRouting: true }, agentProfiles: { items: profiles } }), })); afterEach(() => { diff --git a/apps/web/components/config-chat/config-chat-setup.tsx b/apps/web/components/config-chat/config-chat-setup.tsx index a353baadcb..07f49927e0 100644 --- a/apps/web/components/config-chat/config-chat-setup.tsx +++ b/apps/web/components/config-chat/config-chat-setup.tsx @@ -6,6 +6,8 @@ import { IconLoader2, IconMessageCircle, IconSend2, IconSparkles } from "@tabler import { Button } from "@kandev/ui/button"; import { Textarea } from "@kandev/ui/textarea"; import { useAppStore } from "@/components/state-provider"; +import { useFeature } from "@/hooks/domains/features/use-feature"; +import { isSelectableAgentProfile } from "@/lib/state/slices/settings/types"; import type { QuickChatSessionKind } from "@/lib/state/slices/ui/types"; import { ConfigurationChatToggle } from "@/components/quick-chat/configuration-chat-toggle"; @@ -43,7 +45,11 @@ type ConfigChatSetupProps = ConfigChatSetupBaseProps & function ProfileSelector({ onSelect }: { onSelect: (id: string) => void }) { const profiles = useAppStore((state) => state.agentProfiles.items ?? []); + const dynamicRoutingEnabled = useFeature("dynamicAgentRouting"); const { t } = useTranslation(); + const selectableProfiles = profiles.filter((profile) => + isSelectableAgentProfile(profile, dynamicRoutingEnabled), + ); return (
@@ -53,7 +59,7 @@ function ProfileSelector({ onSelect }: { onSelect: (id: string) => void }) {

{t("configChat:agentProfileHelp")}

- {profiles.map((profile) => ( + {selectableProfiles.map((profile) => ( - - - {t("agents:duplicate")} - + {profile.kind !== "dynamic" && ( + + + {t("agents:duplicate")} + + )} void; + moveCandidate: (index: number, direction: -1 | 1) => void; + removeCandidate: (index: number) => void; + updateCandidate: (index: number, patch: Partial) => void; + updateCandidatePolicy: ( + index: number, + errorClass: DynamicErrorClass, + patch: Partial, + ) => void; +}; + +function candidateLabel(candidate: DynamicAgentCandidate, profiles: AgentProfile[]): string { + return ( + profiles.find((profile) => profile.id === candidate.executionProfileId)?.name ?? + candidate.executionProfileId + ); +} + +// eslint-disable-next-line max-lines-per-function -- keeps the candidate list row actions and policies together. +export function DynamicAgentCandidateList({ + candidates, + concreteProfiles, + availableProfileOptions, + enabledLabel, + addCandidate, + moveCandidate, + removeCandidate, + updateCandidate, + updateCandidatePolicy, +}: DynamicAgentCandidateListProps) { + const { t } = useTranslation(); + + return ( +
+
+
+

{t("agents:dynamicCandidates")}

+

+ {t("agents:dynamicCandidatesDescription")} +

+
+ { + if (value) addCandidate(value); + }} + testId="add-dynamic-candidate" + placeholder={t("agents:addDynamicCandidate")} + searchPlaceholder={t("agents:searchDynamicCandidates")} + emptyMessage={t("agents:noDynamicCandidatesFound")} + ariaLabel={t("agents:addDynamicCandidate")} + triggerClassName="min-h-11 w-full sm:w-auto" + /> +
+ + {candidates.length === 0 ? ( +

+ {t("agents:noDynamicCandidates")} +

+ ) : ( +
    + {candidates.map((candidate, index) => ( +
  1. +
    +
    + {index + 1} + + {candidateLabel(candidate, concreteProfiles)} + +
    +
    + {enabledLabel} + updateCandidate(index, { enabled: checked })} + aria-label={`${enabledLabel}: ${candidateLabel(candidate, concreteProfiles)}`} + /> +
    +
    + + + +
    +
    +
    + updateCandidatePolicy(index, "transient", patch)} + /> + updateCandidatePolicy(index, "hard", patch)} + /> +
    +
  2. + ))} +
+ )} +
+ ); +} diff --git a/apps/web/components/settings/dynamic-agent-policy-editor.test.tsx b/apps/web/components/settings/dynamic-agent-policy-editor.test.tsx new file mode 100644 index 0000000000..f6dfcab414 --- /dev/null +++ b/apps/web/components/settings/dynamic-agent-policy-editor.test.tsx @@ -0,0 +1,69 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useState } from "react"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import type { DynamicErrorPolicy } from "@/lib/types/agent-profile"; +import { DynamicPolicyEditor } from "./dynamic-agent-policy-editor"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (key === "agents:dynamicPolicySchedule") { + return `Retry up to ${options?.retries} times. First wait: ${options?.first}s.`; + } + return key; + }, + }), +})); + +const policy = (): DynamicErrorPolicy => ({ + retry: { enabled: false, maxRetries: 0, initialIntervalSeconds: 0 }, + waitForReset: { enabled: false, maxWaitSeconds: 0 }, + onExhausted: "skip", +}); + +function StatefulEditor({ errorClass }: { errorClass: "transient" | "hard" }) { + const [current, setCurrent] = useState(policy()); + return ( + setCurrent((previous) => ({ ...previous, ...patch }))} + /> + ); +} + +describe("DynamicPolicyEditor", () => { + afterEach(cleanup); + + it("renders class guidance and exposes retry and reset controls", () => { + render( + + + , + ); + + expect(screen.getByTestId("dynamic-policy-transient")).toBeTruthy(); + expect(screen.getByText("agents:dynamicTransientErrors")).toBeTruthy(); + expect(screen.getByRole("switch", { name: "agents:dynamicPolicyRetry" })).toBeTruthy(); + expect(screen.getByRole("switch", { name: "agents:dynamicPolicyWaitForReset" })).toBeTruthy(); + expect(screen.getByTestId("dynamic-policy-option-help-outcome")).toBeTruthy(); + }); + + it("reveals bounded retry and reset-wait fields when enabled", () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole("switch", { name: "agents:dynamicPolicyRetry" })); + expect(screen.getByRole("spinbutton", { name: "agents:dynamicPolicyMaxRetries" })).toBeTruthy(); + expect( + screen.getByRole("spinbutton", { name: "agents:dynamicPolicyInitialInterval" }), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("switch", { name: "agents:dynamicPolicyWaitForReset" })); + expect(screen.getByRole("spinbutton", { name: "agents:dynamicPolicyMaxWait" })).toBeTruthy(); + }); +}); diff --git a/apps/web/components/settings/dynamic-agent-policy-editor.tsx b/apps/web/components/settings/dynamic-agent-policy-editor.tsx new file mode 100644 index 0000000000..6f17925063 --- /dev/null +++ b/apps/web/components/settings/dynamic-agent-policy-editor.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconInfoCircle } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Input } from "@kandev/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { Switch } from "@kandev/ui/switch"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { + SettingsFieldDescription, + SettingsFieldLabel, + SettingsErrorText, +} from "@/components/settings/settings-typography"; +import type { DynamicErrorClass, DynamicErrorPolicy } from "@/lib/types/agent-profile"; + +const MAX_RETRIES = 10; +const MAX_INITIAL_INTERVAL_SECONDS = 3600; +const MAX_WAIT_SECONDS = 7 * 24 * 60 * 60; + +export function isDynamicErrorPolicyValid(policy: DynamicErrorPolicy): boolean { + if (policy.retry.enabled) { + if (policy.retry.maxRetries < 1 || policy.retry.maxRetries > MAX_RETRIES) return false; + if ( + policy.retry.initialIntervalSeconds < 1 || + policy.retry.initialIntervalSeconds > MAX_INITIAL_INTERVAL_SECONDS + ) { + return false; + } + } else if (policy.retry.maxRetries !== 0 || policy.retry.initialIntervalSeconds !== 0) { + return false; + } + if (policy.waitForReset.enabled) { + return ( + policy.waitForReset.maxWaitSeconds >= 1 && + policy.waitForReset.maxWaitSeconds <= MAX_WAIT_SECONDS + ); + } + return policy.waitForReset.maxWaitSeconds === 0; +} + +type PolicyTranslator = (key: string) => string; + +function retryValidationMessage( + policy: DynamicErrorPolicy, + t: PolicyTranslator, +): string | undefined { + if (!policy.retry.enabled) return undefined; + if (policy.retry.maxRetries < 1 || policy.retry.maxRetries > MAX_RETRIES) { + return t("agents:dynamicPolicyMaxRetriesValidation"); + } + if ( + policy.retry.initialIntervalSeconds < 1 || + policy.retry.initialIntervalSeconds > MAX_INITIAL_INTERVAL_SECONDS + ) { + return t("agents:dynamicPolicyInitialIntervalValidation"); + } + return undefined; +} + +function waitValidationMessage( + policy: DynamicErrorPolicy, + t: PolicyTranslator, +): string | undefined { + if ( + policy.waitForReset.enabled && + (policy.waitForReset.maxWaitSeconds < 1 || + policy.waitForReset.maxWaitSeconds > MAX_WAIT_SECONDS) + ) { + return t("agents:dynamicPolicyMaxWaitValidation"); + } + return undefined; +} + +function DynamicPolicyOptionHelp({ option }: { option: "retry" | "wait" | "outcome" }) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const titleKey = `agents:dynamicPolicy${option[0].toUpperCase()}${option.slice(1)}`; + const helpKey = `${titleKey}Help`; + const optionLabel = t(titleKey); + + return ( + + + + + {t(helpKey)} + + ); +} + +// eslint-disable-next-line max-lines-per-function -- keeps the two-class policy form cohesive. +export function DynamicPolicyEditor({ + errorClass, + policy, + onChange, +}: { + errorClass: DynamicErrorClass; + policy: DynamicErrorPolicy; + onChange: (patch: Partial) => void; +}) { + const { t } = useTranslation(); + const isTransient = errorClass === "transient"; + const title = t(isTransient ? "agents:dynamicTransientErrors" : "agents:dynamicHardErrors"); + const description = t( + isTransient + ? "agents:dynamicTransientErrorsDescription" + : "agents:dynamicHardErrorsDescription", + ); + const retryLabel = t("agents:dynamicPolicyRetry"); + const waitLabel = t("agents:dynamicPolicyWaitForReset"); + const outcomeLabel = t("agents:dynamicPolicyAfterExhausted"); + const retryError = retryValidationMessage(policy, t); + const waitError = waitValidationMessage(policy, t); + const retryMaxInvalid = retryError === t("agents:dynamicPolicyMaxRetriesValidation"); + const retryIntervalInvalid = retryError === t("agents:dynamicPolicyInitialIntervalValidation"); + const updateRetry = (patch: Partial) => + onChange({ retry: { ...policy.retry, ...patch } }); + const updateWait = (patch: Partial) => + onChange({ waitForReset: { ...policy.waitForReset, ...patch } }); + const schedule = policy.retry.enabled + ? t("agents:dynamicPolicySchedule", { + first: policy.retry.initialIntervalSeconds, + retries: policy.retry.maxRetries, + }) + : t("agents:dynamicPolicyNoRetry"); + + return ( +
+
+

{title}

+

{description}

+
+
+
+
+ + {retryLabel} + + + {schedule} +
+ + updateRetry( + enabled + ? { + enabled, + maxRetries: policy.retry.maxRetries || 1, + initialIntervalSeconds: policy.retry.initialIntervalSeconds || 5, + } + : { enabled: false, maxRetries: 0, initialIntervalSeconds: 0 }, + ) + } + aria-label={retryLabel} + /> +
+ {policy.retry.enabled && ( +
+ + +
+ )} +
+
+ + {waitLabel} + + + + {t("agents:dynamicPolicyWaitDescription")} + +
+ + updateWait( + enabled + ? { enabled, maxWaitSeconds: policy.waitForReset.maxWaitSeconds || 300 } + : { enabled: false, maxWaitSeconds: 0 }, + ) + } + aria-label={waitLabel} + /> +
+ {policy.waitForReset.enabled && ( + + )} +
+ + {outcomeLabel} + + + +
+
+
+ ); +} diff --git a/apps/web/components/settings/dynamic-agent-profile-editor-draft.ts b/apps/web/components/settings/dynamic-agent-profile-editor-draft.ts new file mode 100644 index 0000000000..5b7e0e0b5a --- /dev/null +++ b/apps/web/components/settings/dynamic-agent-profile-editor-draft.ts @@ -0,0 +1,192 @@ +"use client"; + +import { useState } from "react"; +import { agentProfileId as toAgentProfileId } from "@/lib/types/ids"; +import type { AgentProfile } from "@/lib/types/http"; +import type { + DynamicAgentCandidate, + DynamicAgentPolicy, + DynamicErrorClass, + DynamicErrorPolicy, +} from "@/lib/types/agent-profile"; + +type DynamicAgentProfileEditorDraftProps = { + profile: AgentProfile; + onDraftChange?: (patch: Pick) => void; +}; + +export type DynamicAgentProfileEditorDraft = { + name: string; + candidates: DynamicAgentCandidate[]; + profileEnabled: boolean; + dynamicVersion: number; + updateName: (name: string) => void; + updateProfileEnabled: (enabled: boolean) => void; + addCandidate: (executionProfileId: string) => void; + moveCandidate: (index: number, direction: -1 | 1) => void; + removeCandidate: (index: number) => void; + updateCandidate: (index: number, patch: Partial) => void; + updateCandidatePolicy: ( + index: number, + errorClass: DynamicErrorClass, + patch: Partial, + ) => void; + applyProfile: (profile: AgentProfile) => void; + reset: () => void; +}; + +const defaultDynamicErrorPolicy = (): DynamicErrorPolicy => ({ + retry: { enabled: false, maxRetries: 0, initialIntervalSeconds: 0 }, + waitForReset: { enabled: false, maxWaitSeconds: 0 }, + onExhausted: "skip", +}); + +const defaultDynamicPolicy = (): DynamicAgentPolicy => ({ + version: 1, + transient: defaultDynamicErrorPolicy(), + hard: defaultDynamicErrorPolicy(), +}); + +export function dynamicDraftRevision( + name: string, + candidates: DynamicAgentCandidate[], + enabled: boolean, +): string { + return JSON.stringify({ name, candidates, enabled }); +} + +// eslint-disable-next-line max-lines-per-function -- coordinates one draft and its candidate mutations. +export function useDynamicAgentProfileEditorDraft({ + profile, + onDraftChange, +}: DynamicAgentProfileEditorDraftProps): DynamicAgentProfileEditorDraft { + const [name, setName] = useState(profile.name); + const [candidates, setCandidates] = useState( + profile.dynamic?.candidates ?? [], + ); + const [profileEnabled, setProfileEnabled] = useState(profile.enabled !== false); + const [dynamicVersion, setDynamicVersion] = useState(profile.dynamic?.version ?? 1); + + const initialCandidates = profile.dynamic?.candidates ?? []; + const notifyDraft = ( + nextName: string, + nextCandidates: DynamicAgentCandidate[], + nextEnabled = profileEnabled, + ) => { + onDraftChange?.({ + name: nextName, + enabled: nextEnabled, + dynamic: { version: dynamicVersion, candidates: nextCandidates }, + }); + }; + + const updateName = (nextName: string) => { + setName(nextName); + notifyDraft(nextName, candidates); + }; + + const updateProfileEnabled = (enabled: boolean) => { + setProfileEnabled(enabled); + notifyDraft(name, candidates, enabled); + }; + + const addCandidate = (executionProfileId: string) => { + setCandidates((current) => { + const next = [ + ...current, + { + position: current.length, + executionProfileId: toAgentProfileId(executionProfileId), + enabled: true, + policies: defaultDynamicPolicy(), + }, + ]; + notifyDraft(name, next); + return next; + }); + }; + + const moveCandidate = (index: number, direction: -1 | 1) => { + setCandidates((current) => { + const target = index + direction; + if (target < 0 || target >= current.length) return current; + const next = [...current]; + [next[index], next[target]] = [next[target], next[index]]; + const reordered = next.map((candidate, position) => ({ ...candidate, position })); + notifyDraft(name, reordered); + return reordered; + }); + }; + + const removeCandidate = (index: number) => { + setCandidates((current) => { + const next = current + .filter((_, candidateIndex) => candidateIndex !== index) + .map((candidate, position) => ({ ...candidate, position })); + notifyDraft(name, next); + return next; + }); + }; + + const updateCandidate = (index: number, patch: Partial) => { + setCandidates((current) => { + const next = current.map((candidate, candidateIndex) => + candidateIndex === index ? { ...candidate, ...patch } : candidate, + ); + notifyDraft(name, next); + return next; + }); + }; + + const updateCandidatePolicy = ( + index: number, + errorClass: DynamicErrorClass, + patch: Partial, + ) => { + setCandidates((current) => { + const next = current.map((candidate, candidateIndex) => + candidateIndex === index + ? { + ...candidate, + policies: { + ...candidate.policies, + [errorClass]: { ...candidate.policies[errorClass], ...patch }, + }, + } + : candidate, + ); + notifyDraft(name, next); + return next; + }); + }; + + const reset = () => { + setName(profile.name); + setProfileEnabled(profile.enabled !== false); + setCandidates(initialCandidates); + setDynamicVersion(profile.dynamic?.version ?? 1); + }; + + const applyProfile = (nextProfile: AgentProfile) => { + setName(nextProfile.name); + setProfileEnabled(nextProfile.enabled !== false); + setCandidates(nextProfile.dynamic?.candidates ?? []); + setDynamicVersion(nextProfile.dynamic?.version ?? 1); + }; + + return { + name, + candidates, + profileEnabled, + dynamicVersion, + updateName, + updateProfileEnabled, + addCandidate, + moveCandidate, + removeCandidate, + updateCandidate, + updateCandidatePolicy, + applyProfile, + reset, + }; +} diff --git a/apps/web/components/settings/dynamic-agent-profile-editor-state.ts b/apps/web/components/settings/dynamic-agent-profile-editor-state.ts new file mode 100644 index 0000000000..70dabb5134 --- /dev/null +++ b/apps/web/components/settings/dynamic-agent-profile-editor-state.ts @@ -0,0 +1,268 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useAppStore } from "@/components/state-provider"; +import { useToast } from "@/components/toast-provider"; +import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; +import { isDynamicErrorPolicyValid } from "@/components/settings/dynamic-agent-policy-editor"; +import { updateAgentProfileAction } from "@/app/actions/agents"; +import { useFeature } from "@/hooks/domains/features/use-feature"; +import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; +import type { Agent, AgentProfile } from "@/lib/types/http"; +import type { + DynamicAgentCandidate, + DynamicErrorClass, + DynamicErrorPolicy, +} from "@/lib/types/agent-profile"; +import { + dynamicDraftRevision, + useDynamicAgentProfileEditorDraft, +} from "@/components/settings/dynamic-agent-profile-editor-draft"; + +type DynamicAgentProfileEditorStateProps = { + agent: Agent; + profile: AgentProfile; + onDraftChange?: (patch: Pick) => void; +}; + +export type DynamicAgentProfileEditorState = { + name: string; + profileEnabled: boolean; + standalone: boolean; + routingEnabled: boolean; + enabledLabel: string; + concreteProfiles: AgentProfile[]; + availableProfileOptions: ReturnType[]; + updateName: (name: string) => void; + updateProfileEnabled: (enabled: boolean) => void; + addCandidate: (executionProfileId: string) => void; + moveCandidate: (index: number, direction: -1 | 1) => void; + removeCandidate: (index: number) => void; + updateCandidate: (index: number, patch: Partial) => void; + updateCandidatePolicy: ( + index: number, + errorClass: DynamicErrorClass, + patch: Partial, + ) => void; + candidates: DynamicAgentCandidate[]; +}; + +function dynamicProfilePayload( + name: string, + enabled: boolean, + version: number, + candidates: DynamicAgentCandidate[], +) { + return { + name: name.trim(), + enabled, + dynamic: { + version, + candidates: candidates.map((candidate, position) => ({ + position, + execution_profile_id: candidate.executionProfileId, + enabled: candidate.enabled, + policies: { + version: candidate.policies.version, + transient: { + retry: { + enabled: candidate.policies.transient.retry.enabled, + max_retries: candidate.policies.transient.retry.maxRetries, + initial_interval_seconds: candidate.policies.transient.retry.initialIntervalSeconds, + }, + wait_for_reset: { + enabled: candidate.policies.transient.waitForReset.enabled, + max_wait_seconds: candidate.policies.transient.waitForReset.maxWaitSeconds, + }, + on_exhausted: candidate.policies.transient.onExhausted, + }, + hard: { + retry: { + enabled: candidate.policies.hard.retry.enabled, + max_retries: candidate.policies.hard.retry.maxRetries, + initial_interval_seconds: candidate.policies.hard.retry.initialIntervalSeconds, + }, + wait_for_reset: { + enabled: candidate.policies.hard.waitForReset.enabled, + max_wait_seconds: candidate.policies.hard.waitForReset.maxWaitSeconds, + }, + on_exhausted: candidate.policies.hard.onExhausted, + }, + }, + })), + }, + }; +} + +// eslint-disable-next-line max-lines-per-function -- coordinates persistence and the shared save surface. +export function useDynamicAgentProfileEditorState({ + agent, + profile, + onDraftChange, +}: DynamicAgentProfileEditorStateProps): DynamicAgentProfileEditorState { + const { t } = useTranslation(); + const enabledLabel = t("agents:enabled"); + const { toast } = useToast(); + const routingEnabled = useFeature("dynamicAgentRouting"); + const settingsAgents = useAppStore((state) => state.settingsAgents.items); + const setSettingsAgents = useAppStore((state) => state.setSettingsAgents); + const setAgentProfiles = useAppStore((state) => state.setAgentProfiles); + const draft = useDynamicAgentProfileEditorDraft({ profile, onDraftChange }); + const [savedRevision, setSavedRevision] = useState( + dynamicDraftRevision( + profile.name, + profile.dynamic?.candidates ?? [], + profile.enabled !== false, + ), + ); + const [saving, setSaving] = useState(false); + const standalone = onDraftChange === undefined; + + const concreteProfiles = useMemo( + () => + settingsAgents + .filter((item) => item.name !== "dynamic") + .flatMap((item) => item.profiles) + .filter( + (candidate) => + candidate.kind !== "dynamic" && candidate.enabled !== false && !candidate.workspaceId, + ), + [settingsAgents], + ); + const availableProfileOptions = useMemo( + () => + settingsAgents.flatMap((item) => + item.name === "dynamic" + ? [] + : item.profiles + .filter( + (candidate) => + candidate.kind !== "dynamic" && + candidate.enabled !== false && + !candidate.workspaceId && + !draft.candidates.some((current) => current.executionProfileId === candidate.id), + ) + .map((candidate) => toAgentProfileOption(item, candidate)), + ), + [draft.candidates, settingsAgents], + ); + + const save = async () => { + if ( + !routingEnabled || + !draft.name.trim() || + draft.candidates.length === 0 || + !profile.dynamic + ) { + return; + } + setSaving(true); + try { + const draftPayload = { + name: draft.name.trim(), + enabled: draft.profileEnabled, + dynamic: { + version: draft.dynamicVersion, + candidates: draft.candidates, + }, + }; + const payload = dynamicProfilePayload( + draft.name, + draft.profileEnabled, + draft.dynamicVersion, + draft.candidates, + ); + if (onDraftChange) { + onDraftChange(draftPayload); + return; + } + const updated = await updateAgentProfileAction(profile.id, payload); + const nextAgents = settingsAgents.map((item) => + item.id !== agent.id + ? item + : { + ...item, + profiles: item.profiles.map((itemProfile) => + itemProfile.id === updated.id ? updated : itemProfile, + ), + }, + ); + setSettingsAgents(nextAgents); + setAgentProfiles( + nextAgents.flatMap((item) => + item.profiles.map((itemProfile) => toAgentProfileOption(item, itemProfile)), + ), + ); + draft.applyProfile(updated); + setSavedRevision( + dynamicDraftRevision( + updated.name, + updated.dynamic?.candidates ?? draft.candidates, + updated.enabled !== false, + ), + ); + toast({ title: t("agents:dynamicProfileSaved") }); + } catch (error) { + toast({ + title: t("agents:failedToSaveProfile"), + description: error instanceof Error ? error.message : undefined, + variant: "error", + }); + } finally { + setSaving(false); + } + }; + + const draftRevision = dynamicDraftRevision(draft.name, draft.candidates, draft.profileEnabled); + const policiesValid = draft.candidates.every( + (candidate) => + isDynamicErrorPolicyValid(candidate.policies.transient) && + isDynamicErrorPolicyValid(candidate.policies.hard), + ); + let invalidReason = t("agents:dynamicPolicyValidation"); + if (!draft.name.trim()) invalidReason = t("agents:profileNameRequired"); + else if (draft.candidates.length === 0) invalidReason = t("agents:noDynamicCandidates"); + useSettingsSaveContributor({ + id: `dynamic-profile:${profile.id}`, + revision: draftRevision, + isDirty: standalone && draftRevision !== savedRevision, + canSave: + routingEnabled && + !saving && + Boolean(draft.name.trim()) && + draft.candidates.length > 0 && + policiesValid, + invalidReason, + save, + discard: () => { + if (!standalone) return; + draft.reset(); + setSavedRevision( + dynamicDraftRevision( + profile.name, + profile.dynamic?.candidates ?? [], + profile.enabled !== false, + ), + ); + }, + }); + + return { + name: draft.name, + profileEnabled: draft.profileEnabled, + standalone, + routingEnabled, + enabledLabel, + concreteProfiles, + availableProfileOptions, + updateName: draft.updateName, + updateProfileEnabled: draft.updateProfileEnabled, + addCandidate: draft.addCandidate, + moveCandidate: draft.moveCandidate, + removeCandidate: draft.removeCandidate, + updateCandidate: draft.updateCandidate, + updateCandidatePolicy: draft.updateCandidatePolicy, + candidates: draft.candidates, + }; +} diff --git a/apps/web/components/settings/dynamic-agent-profile-editor.tsx b/apps/web/components/settings/dynamic-agent-profile-editor.tsx new file mode 100644 index 0000000000..c147a22a4b --- /dev/null +++ b/apps/web/components/settings/dynamic-agent-profile-editor.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { IconInfoCircle } from "@tabler/icons-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; +import { Separator } from "@kandev/ui/separator"; +import { Switch } from "@kandev/ui/switch"; +import { AgentLogo } from "@/components/agent-logo"; +import { DynamicAgentCandidateList } from "@/components/settings/dynamic-agent-candidate-list"; +import { ProfileEnabledHelp } from "@/components/settings/profile-enabled-help"; +import { ProfileNameField } from "@/components/settings/profile-form-fields"; +import { useDynamicAgentProfileEditorState } from "@/components/settings/dynamic-agent-profile-editor-state"; +import type { Agent, AgentProfile } from "@/lib/types/http"; + +type DynamicAgentProfileEditorProps = { + agent: Agent; + profile: AgentProfile; + isCreating?: boolean; + /** When supplied, render as a draft editor owned by the parent save flow. */ + onDraftChange?: (patch: Pick) => void; +}; + +function DynamicRoutingPolicyHelp() { + const { t } = useTranslation(); + return ( +
+ +
+

{t("agents:dynamicRoutingPolicyTitle")}

+

{t("agents:dynamicRoutingPolicySwitchable")}

+

{t("agents:dynamicRoutingPolicyExcluded")}

+

{t("agents:dynamicRoutingPolicyRecovery")}

+

{t("agents:dynamicPolicyWaitExplanation")}

+
+
+ ); +} + +function DynamicProfileEditorHeader({ + agent, + profile, + name, + enabled, + onEnabledChange, +}: { + agent: Agent; + profile: AgentProfile; + name: string; + enabled: boolean; + onEnabledChange: (enabled: boolean) => void; +}) { + const { t } = useTranslation(); + return ( + <> +
+
+

+ + {profile.agentDisplayName} • {name} +

+

+ {t("agents:dynamicProfileDescription")} +

+
+
+
+

{t("agents:enabled")}

+ +
+ +
+
+ + + ); +} + +export function DynamicAgentProfileEditor({ + agent, + profile, + isCreating = false, + onDraftChange, +}: DynamicAgentProfileEditorProps) { + const { t } = useTranslation(); + const state = useDynamicAgentProfileEditorState({ agent, profile, onDraftChange }); + + if (!state.routingEnabled) { + return ( + + +

{t("agents:dynamicProfileDisabled")}

+
+
+ ); + } + + const editorContent = ( + <> + + + + + ); + + return ( +
+ {state.standalone && !isCreating && ( + + )} + {state.standalone ? ( + + + {t("agents:dynamicProfileSettings")} + + {editorContent} + + ) : ( +
{editorContent}
+ )} +
+ ); +} diff --git a/apps/web/components/settings/dynamic-agents-card.test.tsx b/apps/web/components/settings/dynamic-agents-card.test.tsx new file mode 100644 index 0000000000..8cee10c255 --- /dev/null +++ b/apps/web/components/settings/dynamic-agents-card.test.tsx @@ -0,0 +1,129 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ReactNode } from "react"; +import type { AgentProfile } from "@/lib/types/http"; +import { agentProfileId } from "@/lib/types/ids"; +import { DynamicAgentsCard } from "./dynamic-agents-card"; + +const { mockUseFeature } = vi.hoisted(() => ({ + mockUseFeature: vi.fn(() => true), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => + ({ + "agents:dynamicAgents": "Dynamic agents", + "agents:dynamicAgentsDescription": "Route profiles", + "agents:dynamicProfileDisabled": "Routing is disabled", + "agents:dynamicCandidates": "Candidates", + "agents:newProfile": "New profile", + "agents:noProfilesYetShort": "No profiles yet", + "agents:profileName": "Profile", + "sidebar:disabledBadge": "Disabled", + })[key] ?? key, + }), +})); + +vi.mock("@/hooks/domains/features/use-feature", () => ({ + useFeature: mockUseFeature, +})); + +vi.mock("@/components/routing/app-link", () => ({ + default: ({ children, ...props }: { children?: ReactNode; href: string }) => ( + {children} + ), +})); + +const dynamicProfile: AgentProfile = { + id: agentProfileId("dynamic-profile-1"), + kind: "dynamic", + name: "Balanced", + agentId: "dynamic", + agentDisplayName: "Dynamic", + model: "", + allowIndexing: false, + autoApprove: false, + cliFlags: [], + cliPassthrough: false, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + dynamic: { + version: 1, + candidates: [ + { + position: 0, + executionProfileId: agentProfileId("concrete-profile-1"), + enabled: true, + policies: { + version: 1, + transient: { + retry: { enabled: false, maxRetries: 0, initialIntervalSeconds: 0 }, + waitForReset: { enabled: false, maxWaitSeconds: 0 }, + onExhausted: "skip", + }, + hard: { + retry: { enabled: false, maxRetries: 0, initialIntervalSeconds: 0 }, + waitForReset: { enabled: false, maxWaitSeconds: 0 }, + onExhausted: "skip", + }, + }, + }, + { + position: 1, + executionProfileId: agentProfileId("concrete-profile-2"), + enabled: true, + policies: { + version: 1, + transient: { + retry: { enabled: false, maxRetries: 0, initialIntervalSeconds: 0 }, + waitForReset: { enabled: false, maxWaitSeconds: 0 }, + onExhausted: "stop", + }, + hard: { + retry: { enabled: false, maxRetries: 0, initialIntervalSeconds: 0 }, + waitForReset: { enabled: false, maxWaitSeconds: 0 }, + onExhausted: "stop", + }, + }, + }, + ], + }, +}; + +describe("DynamicAgentsCard", () => { + afterEach(() => { + cleanup(); + mockUseFeature.mockReturnValue(true); + }); + + it("does not render when dynamic routing is disabled", () => { + mockUseFeature.mockReturnValue(false); + + render(); + + expect(screen.queryByTestId("dynamic-agents-card")).toBeNull(); + }); + + it("offers creation and links each existing dynamic profile", () => { + render(); + + expect(screen.getByTestId("new-dynamic-profile").getAttribute("href")).toBe( + "/settings/agents/dynamic?mode=create", + ); + expect(screen.getByTestId("dynamic-profile-route-dynamic-profile-1").getAttribute("href")).toBe( + "/settings/agents/dynamic/profiles/dynamic-profile-1", + ); + expect(screen.getByText("Balanced")).toBeTruthy(); + expect(screen.getByText("Candidates: 2")).toBeTruthy(); + }); + + it("shows an empty state while keeping the creation action available", () => { + render(); + + expect(screen.getByTestId("dynamic-agents-empty").textContent).toContain("No profiles yet"); + expect(screen.getByTestId("new-dynamic-profile").getAttribute("href")).toBe( + "/settings/agents/dynamic?mode=create", + ); + }); +}); diff --git a/apps/web/components/settings/dynamic-agents-card.tsx b/apps/web/components/settings/dynamic-agents-card.tsx new file mode 100644 index 0000000000..f8e78b633c --- /dev/null +++ b/apps/web/components/settings/dynamic-agents-card.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { IconPlus, IconRoute } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import Link from "@/components/routing/app-link"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; +import { DisabledBadge } from "@/components/settings/record-badges"; +import { useFeature } from "@/hooks/domains/features/use-feature"; +import { DYNAMIC_AGENT_NAME } from "@/lib/settings/agent-display-order"; +import type { Agent, AgentProfile } from "@/lib/types/http"; + +const dynamicProfileRoute = (profileId: string): string => + `/settings/agents/${encodeURIComponent(DYNAMIC_AGENT_NAME)}/profiles/${encodeURIComponent(profileId)}`; + +function DynamicProfileRow({ profile }: { profile: AgentProfile }) { + const { t } = useTranslation(); + const candidateCount = profile.dynamic?.candidates.length ?? 0; + + return ( + +
+ + + {profile.name || t("agents:profileName")} + + {profile.enabled === false && } +
+ + {t("agents:dynamicCandidates")}: {candidateCount} + + + ); +} + +type DynamicAgentsCardProps = { + agent?: Pick; +}; + +export function DynamicAgentsCard({ agent }: DynamicAgentsCardProps) { + const { t } = useTranslation(); + const routingEnabled = useFeature("dynamicAgentRouting"); + + if (!routingEnabled) return null; + + const profiles = agent?.profiles ?? []; + + return ( + + +
+ + + {t("agents:dynamicAgents")} + +

+ {t("agents:dynamicAgentsDescription")} +

+
+ +
+ + {profiles.length > 0 ? ( + profiles.map((profile) => ) + ) : ( +

+ {t("agents:noProfilesYetShort")} +

+ )} +
+
+ ); +} diff --git a/apps/web/components/settings/profile-form-fields.tsx b/apps/web/components/settings/profile-form-fields.tsx index 3aa723be26..2ee09188aa 100644 --- a/apps/web/components/settings/profile-form-fields.tsx +++ b/apps/web/components/settings/profile-form-fields.tsx @@ -404,16 +404,11 @@ function NameField({ const { t } = useTranslation(); return (
-
- {t("agents:profileName")} - onChange({ name: event.target.value })} - placeholder={t("agents:defaultProfile")} - data-settings-dirty={baselineName !== undefined && profile.name !== baselineName} - /> -
+ onChange({ name: value })} + dirty={baselineName !== undefined && profile.name !== baselineName} + /> {canRemove && onRemove && ( + + {pendingWait && ( + + )} + +
+ ); +} + +export function DynamicRouteRecovery({ session }: { session: TaskSession | null }) { + const [pendingAction, setPendingAction] = useState(null); + const updateSession = useAppStore((state) => state.setTaskSession); + + const handleAction = useCallback( + async (action: RouteAction) => { + if (!session) return; + setPendingAction(action); + const result = await applyRouteAction(session, action); + setPendingAction(null); + if (result) { + updateSession(applyRouteActionResult(session, result)); + } + }, + [session, updateSession], + ); + + if ( + !session || + !session.route_state || + !["waiting", "waiting_for_reset", "retry_wait", "retrying", "action_required"].includes( + session.route_state, + ) || + session.route_generation === undefined + ) { + return null; + } + const pendingWait = + session.route_state === "waiting_for_reset" || session.route_state === "retry_wait"; + + return ( +
+ + void handleAction(action)} + /> +
+ ); +} diff --git a/apps/web/components/task/handoff-profile-menu-items.test.ts b/apps/web/components/task/handoff-profile-menu-items.test.ts index 61b050be0f..ca34636385 100644 --- a/apps/web/components/task/handoff-profile-menu-items.test.ts +++ b/apps/web/components/task/handoff-profile-menu-items.test.ts @@ -40,6 +40,7 @@ const mockUseTaskExecutorProfile = vi.fn( vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: Record) => unknown) => selector({ + features: { dynamicAgentRouting: true }, agentProfiles: { items: mockProfiles }, }), })); diff --git a/apps/web/components/task/handoff-profile-menu-items.tsx b/apps/web/components/task/handoff-profile-menu-items.tsx index 44bbe75721..e2a14a66dd 100644 --- a/apps/web/components/task/handoff-profile-menu-items.tsx +++ b/apps/web/components/task/handoff-profile-menu-items.tsx @@ -18,6 +18,7 @@ import { useAppStore } from "@/components/state-provider"; import { isHealthyAgentProfile } from "@/hooks/domains/settings/use-healthy-agent-profiles"; import { useRemoteAuthSpecs } from "@/hooks/domains/settings/use-remote-auth-specs"; import { useTaskExecutorProfile } from "@/hooks/domains/session/use-task-executor-profile"; +import { useFeature } from "@/hooks/domains/features/use-feature"; import { isAgentConfiguredOnExecutor, shouldFilterHandoffByHostHealth, @@ -46,12 +47,13 @@ function profileDisplayLabel(profile: AgentProfileOption): { label: string; agen export function useHandoffProfiles(taskId: string, enabled = true): HandoffProfile[] { const agentProfiles = useAppStore((s) => s.agentProfiles.items); const executorProfile = useTaskExecutorProfile(taskId, enabled); + const dynamicRoutingEnabled = useFeature("dynamicAgentRouting"); const { specs: authSpecs, loaded: authLoaded } = useRemoteAuthSpecs(); return useMemo(() => { const filterByHostHealth = shouldFilterHandoffByHostHealth(executorProfile); return agentProfiles - .filter(isSelectableAgentProfile) + .filter((profile) => isSelectableAgentProfile(profile, dynamicRoutingEnabled)) .filter((profile) => !filterByHostHealth || isHealthyAgentProfile(profile)) .map((profile) => { const { label, agentName } = profileDisplayLabel(profile); @@ -61,7 +63,7 @@ export function useHandoffProfiles(taskId: string, enabled = true): HandoffProfi } return { id: profile.id, label, agentName, disabled }; }); - }, [agentProfiles, executorProfile, authSpecs, authLoaded]); + }, [agentProfiles, dynamicRoutingEnabled, executorProfile, authSpecs, authLoaded]); } /** @@ -73,7 +75,11 @@ export function useHandoffProfiles(taskId: string, enabled = true): HandoffProfi */ export function useHasSelectableAgentProfiles(): boolean { const agentProfiles = useAppStore((s) => s.agentProfiles.items); - return useMemo(() => agentProfiles.some(isSelectableAgentProfile), [agentProfiles]); + const dynamicRoutingEnabled = useFeature("dynamicAgentRouting"); + return useMemo( + () => agentProfiles.some((profile) => isSelectableAgentProfile(profile, dynamicRoutingEnabled)), + [agentProfiles, dynamicRoutingEnabled], + ); } function HandoffProfileList({ diff --git a/apps/web/components/task/mobile/mobile-sessions-section.test.tsx b/apps/web/components/task/mobile/mobile-sessions-section.test.tsx index cd3020fdb7..a0f5a6d002 100644 --- a/apps/web/components/task/mobile/mobile-sessions-section.test.tsx +++ b/apps/web/components/task/mobile/mobile-sessions-section.test.tsx @@ -22,6 +22,7 @@ vi.mock("@/hooks/use-task-sessions", () => ({ vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: unknown) => unknown) => selector({ + features: { dynamicAgentRouting: false }, tasks: { activeSessionId: mocks.activeSessionId }, agentProfiles: { items: mocks.agentProfiles }, kanban: { tasks: [{ id: "task-1", primarySessionId: "session-a" }] }, diff --git a/apps/web/components/task/new-session-dialog.test.tsx b/apps/web/components/task/new-session-dialog.test.tsx index c8b925dc68..20f1f48e3a 100644 --- a/apps/web/components/task/new-session-dialog.test.tsx +++ b/apps/web/components/task/new-session-dialog.test.tsx @@ -24,6 +24,7 @@ const BASE_PROFILE: AgentProfileOption = { }; const mockState = { + features: { dynamicAgentRouting: true }, kanban: { workflowId: null, tasks: [{ id: "task-1", title: "Task title" }], diff --git a/apps/web/components/task/new-session-dialog.tsx b/apps/web/components/task/new-session-dialog.tsx index 335decd2da..3b48ec4cf5 100644 --- a/apps/web/components/task/new-session-dialog.tsx +++ b/apps/web/components/task/new-session-dialog.tsx @@ -14,13 +14,9 @@ import type { TaskFormInputsHandle } from "@/components/task-create-dialog-types import { useAgentProfileOptions } from "@/components/task-create-dialog-options"; import { useSummarizeSession } from "@/hooks/use-summarize-session"; import { useTaskSessions } from "@/hooks/use-task-sessions"; -import { useRemoteAuthSpecs } from "@/hooks/domains/settings/use-remote-auth-specs"; import { useTaskExecutorProfile } from "@/hooks/domains/session/use-task-executor-profile"; -import { - isAgentConfiguredOnExecutor, - shouldFilterHandoffByHostHealth, -} from "@/lib/agent-executor-compat"; -import { isHealthyAgentProfile } from "@/hooks/domains/settings/use-healthy-agent-profiles"; +import { useCompatibleAgentProfiles } from "@/hooks/domains/session/use-compatible-agent-profiles"; +import { useFeature } from "@/hooks/domains/features/use-feature"; import type { AgentProfileOption } from "@/lib/state/slices"; import { isSelectableAgentProfile } from "@/lib/state/slices/settings/types"; import type { ExecutorProfile } from "@/lib/types/http"; @@ -54,6 +50,7 @@ function agentProfileDisplayLabel(profile: AgentProfileOption): string { function useNewSessionDialogState(taskId: string) { const { t } = useTranslation(); + const dynamicRoutingEnabled = useFeature("dynamicAgentRouting"); const resolvedWorkspaceId = useAppStore((state) => resolveComposerWorkspaceId({ sessionId: null, @@ -102,7 +99,9 @@ function useNewSessionDialogState(taskId: string) { // The default for a NEW session must be selectable: a current session // that uses a now-disabled profile still runs (no effect on existing // sessions), but the dialog falls back to the first enabled profile. - const selectableProfiles = agentProfiles.filter(isSelectableAgentProfile); + const selectableProfiles = agentProfiles.filter((profile) => + isSelectableAgentProfile(profile, dynamicRoutingEnabled), + ); const profileIsValid = selectableProfiles.some((p: { id: string }) => p.id === sessionProfileId); const effectiveDefaultProfileId: string = profileIsValid ? sessionProfileId @@ -165,24 +164,6 @@ function isMissingCompatibleProfile( return !hasCompatibleProfiles; } -function useCompatibleAgentProfiles( - agentProfiles: AgentProfileOption[], - executorProfile: ExecutorProfile | null, - filterByHostHealth: boolean, -): AgentProfileOption[] { - const { specs: authSpecs, loaded: authLoaded } = useRemoteAuthSpecs(); - return useMemo(() => { - const selectable = agentProfiles.filter(isSelectableAgentProfile); - const healthFiltered = filterByHostHealth - ? selectable.filter((profile) => isHealthyAgentProfile(profile)) - : selectable; - if (!executorProfile || !authLoaded) return healthFiltered; - return healthFiltered.filter((ap) => - isAgentConfiguredOnExecutor(ap, executorProfile, authSpecs), - ); - }, [agentProfiles, executorProfile, authSpecs, authLoaded, filterByHostHealth]); -} - function useHandoffAutoSummarize( handoff: HandoffPreset | undefined, contextValue: string, @@ -279,7 +260,7 @@ function useSessionProfileSelection({ const compatibleAgentProfiles = useCompatibleAgentProfiles( agentProfiles, executorProfile, - Boolean(handoff && shouldFilterHandoffByHostHealth(executorProfile)), + handoff, ); useEnforceCompatibleProfile(compatibleAgentProfiles, selectedProfileId, setSelectedProfileId); const profileOptions = useAgentProfileOptions(compatibleAgentProfiles); diff --git a/apps/web/e2e/fixtures/test-base.ts b/apps/web/e2e/fixtures/test-base.ts index 4f2e5257a2..c97aacd9a9 100644 --- a/apps/web/e2e/fixtures/test-base.ts +++ b/apps/web/e2e/fixtures/test-base.ts @@ -63,7 +63,14 @@ async function waitForSeedAgentProfile( // profile because mock-agent is disabled there. When the E2E profile is // restored, create a fresh disposable profile instead of waiting for a // user-deleted row that the backend correctly will not resurrect. - const agent = agents.find((candidate) => candidate.name === "mock-agent") ?? agents[0]; + // Virtual families such as Dynamic are settings containers, not + // launchable profile owners. They may be the first API row after the + // registry keeps them visible with routing disabled, so never use one + // as the replacement-profile target. Use the stable ID because the + // display name is localized and capitalized. + const agent = + agents.find((candidate) => candidate.name === "mock-agent") ?? + agents.find((candidate) => candidate.id !== "dynamic"); if (agent) { const replacement = await apiClient.createAgentProfile(agent.id, "mock-fast", { model: "mock-fast", @@ -200,7 +207,12 @@ export const test = backendFixture.extend< while (Date.now() < agentsDeadline) { const { agents } = await apiClient.listAgents(); lastAgentCount = agents.length; - agentProfileId = agents[0]?.profiles[0]?.id; + // Virtual families (for example Dynamic) sort before concrete + // providers but do not own executable profiles. Search all returned + // families instead of assuming the first row is launchable. + agentProfileId = agents + .filter((agent) => agent.id !== "dynamic") + .flatMap((agent) => agent.profiles ?? [])[0]?.id; if (agentProfileId) break; await dwell( 250, diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 6c79d934e3..c530046985 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -513,12 +513,19 @@ export class ApiClient { "GET", "/api/v1/agents", ); - return { - ...response, - agents: response.agents.map((agent) => ({ + // The Dynamic family is intentionally ranked first by the product API so + // settings can present its dedicated card first. Most E2E profile + // factories predate virtual families and use the first agent as a + // launchable owner, so keep concrete families first in this test client. + const agents = response.agents + .map((agent) => ({ ...agent, profiles: (agent.profiles ?? []).map(normalizeAgentProfile), - })), + })) + .sort((a, b) => Number(a.id === "dynamic") - Number(b.id === "dynamic")); + return { + ...response, + agents, }; } diff --git a/apps/web/e2e/tests/automations-pr-merged-trigger.spec.ts b/apps/web/e2e/tests/automations-pr-merged-trigger.spec.ts index 35584b1e67..187fc07c1f 100644 --- a/apps/web/e2e/tests/automations-pr-merged-trigger.spec.ts +++ b/apps/web/e2e/tests/automations-pr-merged-trigger.spec.ts @@ -281,8 +281,15 @@ test.describe("automations — Pull request merged trigger", () => { await expect(automationsPage.saveButton).toBeEnabled({ timeout: 5_000 }); await automationsPage.saveButton.click(); - // Wait for the save to complete — the floating save button disappears when the dirty state clears - await expect(automationsPage.saveButton).not.toBeVisible({ timeout: 10_000 }); + // Wait for the shared save coordinator to finish. The button's accessible + // name changes from "Save changes" to "Saving changes" while the request + // is in flight, so waiting for that name to disappear can reload the page + // before trigger persistence completes. + await expect(testPage.getByTestId("settings-floating-save")).toHaveAttribute( + "data-status", + "saved", + { timeout: 10_000 }, + ); // Reload the page to verify the saved state is persisted await testPage.reload(); diff --git a/apps/web/e2e/tests/session/session-handoff-unhealthy-profile.spec.ts b/apps/web/e2e/tests/session/session-handoff-unhealthy-profile.spec.ts index 4f86e91db8..2cf83dbfbb 100644 --- a/apps/web/e2e/tests/session/session-handoff-unhealthy-profile.spec.ts +++ b/apps/web/e2e/tests/session/session-handoff-unhealthy-profile.spec.ts @@ -57,12 +57,17 @@ async function createProfiles( apiClient: InstanceType, ) { const { agents } = await apiClient.listAgents(); - if (agents.length === 0) throw new Error("no agents available in test fixtures"); - const agentId = agents[0].id; + const agent = + agents.find((candidate) => candidate.name === "mock-agent") ?? + agents.find((candidate) => candidate.id !== "dynamic"); + if (!agent) throw new Error("no concrete agents available in test fixtures"); + const agentId = agent.id; const profileA = await apiClient.createAgentProfile(agentId, "Handoff Filter Profile A", { model: "mock-fast", }); - const profileBAgentId = agents[1]?.id ?? agentId; + const profileBAgentId = + agents.find((candidate) => candidate.id !== "dynamic" && candidate.id !== agentId)?.id ?? + agentId; const profileB = await apiClient.createAgentProfile(profileBAgentId, "Handoff Filter Profile B", { model: "mock-slow", }); diff --git a/apps/web/e2e/tests/settings/dynamic-agent-profile-card.spec.ts b/apps/web/e2e/tests/settings/dynamic-agent-profile-card.spec.ts new file mode 100644 index 0000000000..2303a39075 --- /dev/null +++ b/apps/web/e2e/tests/settings/dynamic-agent-profile-card.spec.ts @@ -0,0 +1,93 @@ +import { test, expect } from "../../fixtures/test-base"; + +test.describe("Dynamic Agents settings card", () => { + test("renders first and opens dynamic profile creation", async ({ testPage, backend }) => { + test.setTimeout(60_000); + const releaseFeature = await backend.useEnv({ + KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING: "true", + }); + + try { + await testPage.goto("/settings/agents"); + + const agentCards = testPage.locator( + '[data-testid="dynamic-agents-card"], [data-testid^="agent-group-"]', + ); + await expect(agentCards.first()).toHaveAttribute("data-testid", "dynamic-agents-card"); + + const createProfile = testPage.getByTestId("new-dynamic-profile"); + await expect(createProfile).toBeVisible({ timeout: 15_000 }); + await expect(createProfile).toBeEnabled(); + await expect(createProfile).toHaveAttribute("href", "/settings/agents/dynamic?mode=create"); + + await createProfile.click(); + await expect(testPage).toHaveURL(/\/settings\/agents\/dynamic\?mode=create$/); + await expect(testPage.getByTestId("dynamic-profile-enabled-toggle")).toHaveCount(0); + } finally { + await releaseFeature(); + } + }); + + test("uses the shared searchable picker and explains route actions", async ({ + testPage, + backend, + apiClient, + seedData, + }) => { + test.setTimeout(60_000); + const releaseFeature = await backend.useEnv({ + KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING: "true", + }); + + try { + await expect + .poll( + async () => { + const { agents } = await apiClient.listAgents(); + return agents.some((agent) => + agent.profiles?.some((profile) => profile.id === seedData.agentProfileId), + ); + }, + { timeout: 15_000 }, + ) + .toBe(true); + const { agents } = await apiClient.listAgents(); + const candidate = agents + .flatMap((agent) => agent.profiles ?? []) + .find((profile) => profile.id === seedData.agentProfileId); + if (!candidate) throw new Error("The E2E fixture must provide a concrete global profile"); + + await testPage.goto("/settings/agents"); + await testPage.getByTestId("new-dynamic-profile").click(); + await expect(testPage).toHaveURL(/\/settings\/agents\/dynamic\?mode=create$/); + await expect(testPage.getByTestId("dynamic-profile-name")).toBeVisible({ timeout: 15_000 }); + await expect(testPage.getByTestId("dynamic-routing-policy-help")).toBeVisible(); + await expect(testPage.getByTestId("dynamic-routing-policy-help")).toContainText( + "authentication or subscription problems, missing credentials or configuration, quota or rate limits", + ); + + const picker = testPage.getByTestId("add-dynamic-candidate"); + await picker.click(); + const dropdown = testPage.getByTestId("add-dynamic-candidate-dropdown"); + await expect(dropdown).toBeVisible(); + await expect(dropdown.getByTestId("agent-profile-picker-agent-icon").first()).toBeVisible(); + await dropdown.getByPlaceholder("Search agent profiles...").fill(candidate.agentDisplayName); + await expect(dropdown.locator(`[data-value="${candidate.id}"]`)).toBeVisible(); + await dropdown.locator(`[data-value="${candidate.id}"]`).click(); + + const candidateRow = testPage.getByTestId("dynamic-profile-candidates").locator("li"); + await expect(candidateRow).toContainText(candidate.name); + await expect(testPage.getByTestId("dynamic-policy-transient")).toBeVisible(); + await expect(testPage.getByTestId("dynamic-policy-hard")).toBeVisible(); + const help = testPage + .getByTestId("dynamic-policy-transient") + .getByTestId("dynamic-policy-option-help-outcome"); + await help.hover(); + await expect(testPage.getByRole("tooltip")).toContainText( + "after reset waiting and all same-candidate retries are exhausted", + ); + } finally { + await releaseFeature(); + } + }); +}); diff --git a/apps/web/e2e/tests/settings/mobile-dynamic-agent-profile-card.spec.ts b/apps/web/e2e/tests/settings/mobile-dynamic-agent-profile-card.spec.ts new file mode 100644 index 0000000000..1626442cd2 --- /dev/null +++ b/apps/web/e2e/tests/settings/mobile-dynamic-agent-profile-card.spec.ts @@ -0,0 +1,115 @@ +import { test, expect } from "../../fixtures/test-base"; + +test.describe("Dynamic Agents settings card on mobile", () => { + test("keeps the first-card creation path reachable by touch", async ({ testPage, backend }) => { + test.setTimeout(60_000); + const releaseFeature = await backend.useEnv({ + KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING: "true", + }); + + try { + await testPage.goto("/settings/agents"); + + await expect + .poll( + async () => + testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + { timeout: 15_000 }, + ) + .toBe(true); + + const agentCards = testPage.locator( + '[data-testid="dynamic-agents-card"], [data-testid^="agent-group-"]', + ); + await expect(agentCards.first()).toHaveAttribute("data-testid", "dynamic-agents-card"); + + const createProfile = testPage.getByTestId("new-dynamic-profile"); + await expect(createProfile).toBeVisible({ timeout: 15_000 }); + await expect + .poll( + async () => { + const box = await createProfile.boundingBox(); + return box ? Math.min(box.width, box.height) : null; + }, + { timeout: 10_000 }, + ) + .toBeGreaterThanOrEqual(44); + + await createProfile.tap(); + await expect(testPage).toHaveURL(/\/settings\/agents\/dynamic\?mode=create$/); + } finally { + await releaseFeature(); + } + }); + + test("uses the shared profile picker and keeps route help reachable", async ({ + testPage, + backend, + apiClient, + seedData, + }) => { + test.setTimeout(60_000); + const releaseFeature = await backend.useEnv({ + KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING: "true", + }); + + try { + await expect + .poll( + async () => { + const { agents } = await apiClient.listAgents(); + return agents.some((agent) => + agent.profiles?.some((profile) => profile.id === seedData.agentProfileId), + ); + }, + { timeout: 15_000 }, + ) + .toBe(true); + const { agents } = await apiClient.listAgents(); + const candidate = agents + .flatMap((agent) => agent.profiles ?? []) + .find((profile) => profile.id === seedData.agentProfileId); + if (!candidate) throw new Error("The E2E fixture must provide a concrete global profile"); + + await testPage.goto("/settings/agents"); + await testPage.getByTestId("new-dynamic-profile").tap(); + await expect(testPage).toHaveURL(/\/settings\/agents\/dynamic\?mode=create$/); + await expect(testPage.getByTestId("dynamic-profile-enabled-toggle")).toHaveCount(0); + await expect(testPage.getByTestId("dynamic-profile-name")).toBeVisible({ timeout: 15_000 }); + await expect(testPage.getByTestId("dynamic-routing-policy-help")).toBeVisible(); + await expect(testPage.getByTestId("dynamic-routing-policy-help")).toContainText( + "authentication or subscription problems, missing credentials or configuration, quota or rate limits", + ); + + const picker = testPage.getByTestId("add-dynamic-candidate"); + await picker.tap(); + const dropdown = testPage.getByTestId("add-dynamic-candidate-dropdown"); + await expect(dropdown).toBeVisible(); + await dropdown.getByPlaceholder("Search agent profiles...").fill(candidate.agentDisplayName); + await expect(dropdown.getByTestId("agent-profile-picker-agent-icon").first()).toBeVisible(); + await dropdown.locator(`[data-value="${candidate.id}"]`).tap(); + + await expect(testPage.getByTestId("dynamic-profile-candidates").locator("li")).toContainText( + candidate.name, + ); + await expect(testPage.getByTestId("dynamic-policy-transient")).toBeVisible(); + await expect(testPage.getByTestId("dynamic-policy-hard")).toBeVisible(); + await testPage + .getByTestId("dynamic-policy-transient") + .getByTestId("dynamic-policy-option-help-outcome") + .tap(); + await expect(testPage.getByRole("tooltip")).toContainText( + "after reset waiting and all same-candidate retries are exhausted", + ); + await expect + .poll( + async () => + testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + { timeout: 10_000 }, + ) + .toBe(true); + } finally { + await releaseFeature(); + } + }); +}); diff --git a/apps/web/hooks/domains/session/use-compatible-agent-profiles.ts b/apps/web/hooks/domains/session/use-compatible-agent-profiles.ts new file mode 100644 index 0000000000..1c7ab53fe7 --- /dev/null +++ b/apps/web/hooks/domains/session/use-compatible-agent-profiles.ts @@ -0,0 +1,42 @@ +import { useMemo } from "react"; + +import { useFeature } from "@/hooks/domains/features/use-feature"; +import { useRemoteAuthSpecs } from "@/hooks/domains/settings/use-remote-auth-specs"; +import { isHealthyAgentProfile } from "@/hooks/domains/settings/use-healthy-agent-profiles"; +import { + isAgentConfiguredOnExecutor, + shouldFilterHandoffByHostHealth, +} from "@/lib/agent-executor-compat"; +import type { AgentProfileOption } from "@/lib/state/slices"; +import { isSelectableAgentProfile } from "@/lib/state/slices/settings/types"; +import type { ExecutorProfile } from "@/lib/types/http"; +import type { HandoffPreset } from "@/components/task/handoff-types"; + +export function useCompatibleAgentProfiles( + agentProfiles: AgentProfileOption[], + executorProfile: ExecutorProfile | null, + handoff?: HandoffPreset, +): AgentProfileOption[] { + const dynamicRoutingEnabled = useFeature("dynamicAgentRouting"); + const { specs: authSpecs, loaded: authLoaded } = useRemoteAuthSpecs(); + const filterByHostHealth = Boolean(handoff && shouldFilterHandoffByHostHealth(executorProfile)); + + return useMemo(() => { + const selectable = agentProfiles.filter( + (profile) => + isSelectableAgentProfile(profile, dynamicRoutingEnabled) && + (!filterByHostHealth || isHealthyAgentProfile(profile)), + ); + if (!executorProfile || !authLoaded) return selectable; + return selectable.filter((profile) => + isAgentConfiguredOnExecutor(profile, executorProfile, authSpecs), + ); + }, [ + agentProfiles, + dynamicRoutingEnabled, + executorProfile, + authSpecs, + authLoaded, + filterByHostHealth, + ]); +} diff --git a/apps/web/hooks/domains/settings/use-healthy-agent-profiles.test.ts b/apps/web/hooks/domains/settings/use-healthy-agent-profiles.test.ts new file mode 100644 index 0000000000..5e8f81a58a --- /dev/null +++ b/apps/web/hooks/domains/settings/use-healthy-agent-profiles.test.ts @@ -0,0 +1,38 @@ +import { cleanup, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useHealthyAgentProfiles } from "./use-healthy-agent-profiles"; + +const state = { + features: { dynamicAgentRouting: false }, + agentProfiles: { + items: [ + { id: "concrete", kind: "concrete", capability_status: "ok" }, + { id: "dynamic", kind: "dynamic", capability_status: "ok" }, + ], + }, +}; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (value: typeof state) => unknown) => selector(state), +})); + +afterEach(() => { + cleanup(); + state.features.dynamicAgentRouting = false; +}); + +describe("useHealthyAgentProfiles", () => { + it("hides dynamic profiles from new workflow selections while the flag is off", () => { + const { result } = renderHook(() => useHealthyAgentProfiles()); + + expect(result.current.map((profile) => profile.id)).toEqual(["concrete"]); + }); + + it("includes dynamic profiles when routing is enabled", () => { + state.features.dynamicAgentRouting = true; + + const { result } = renderHook(() => useHealthyAgentProfiles()); + + expect(result.current.map((profile) => profile.id)).toEqual(["concrete", "dynamic"]); + }); +}); diff --git a/apps/web/hooks/domains/settings/use-healthy-agent-profiles.ts b/apps/web/hooks/domains/settings/use-healthy-agent-profiles.ts index 0c0599382b..915279a339 100644 --- a/apps/web/hooks/domains/settings/use-healthy-agent-profiles.ts +++ b/apps/web/hooks/domains/settings/use-healthy-agent-profiles.ts @@ -1,6 +1,7 @@ "use client"; import { useAppStore } from "@/components/state-provider"; +import { useFeature } from "@/hooks/domains/features/use-feature"; import type { AgentProfileOption } from "@/lib/state/slices"; /** @@ -26,5 +27,8 @@ export function isHealthyAgentProfile(profile: AgentProfileOption, selectedId?: */ export function useHealthyAgentProfiles(selectedId?: string): AgentProfileOption[] { const agentProfiles = useAppStore((s) => s.agentProfiles.items); - return agentProfiles.filter((p) => isHealthyAgentProfile(p, selectedId)); + const dynamicRoutingEnabled = useFeature("dynamicAgentRouting"); + return agentProfiles.filter( + (p) => (dynamicRoutingEnabled || p.kind !== "dynamic") && isHealthyAgentProfile(p, selectedId), + ); } diff --git a/apps/web/lib/api/domains/agent-profile-normalize.test.ts b/apps/web/lib/api/domains/agent-profile-normalize.test.ts index e741682501..dc1700c8e8 100644 --- a/apps/web/lib/api/domains/agent-profile-normalize.test.ts +++ b/apps/web/lib/api/domains/agent-profile-normalize.test.ts @@ -47,6 +47,7 @@ const expectedCamelCaseProfile = { updatedAt: "2026-01-02T00:00:00Z", }; +// eslint-disable-next-line max-lines-per-function -- keeps the canonical wire-shape matrix together. describe("normalizeAgentProfile", () => { it("converts snake_case wire payload to canonical camelCase", () => { const result = normalizeAgentProfile(snakeCaseWirePayload); @@ -136,6 +137,81 @@ describe("normalizeAgentProfile", () => { expect(result.fallbackModel).toBe(""); expect(result.autoFallback).toBe(false); }); + + it("normalizes a dynamic profile document and preserves candidate order", () => { + const result = normalizeAgentProfile({ + id: "dynamic-profile", + kind: "dynamic", + dynamic: { + version: 4, + candidates: [ + { + position: 0, + execution_profile_id: "primary", + enabled: true, + policies: { + version: 1, + transient: { + retry: { enabled: true, max_retries: 2, initial_interval_seconds: 5 }, + wait_for_reset: { enabled: true, max_wait_seconds: 300 }, + on_exhausted: "skip", + }, + hard: { + retry: { enabled: false, max_retries: 0, initial_interval_seconds: 0 }, + wait_for_reset: { enabled: false, max_wait_seconds: 0 }, + on_exhausted: "stop", + }, + }, + }, + ], + }, + }); + expect(result.kind).toBe("dynamic"); + expect(result.dynamic).toEqual({ + version: 4, + candidates: [ + { + position: 0, + executionProfileId: "primary", + enabled: true, + policies: { + version: 1, + transient: { + retry: { enabled: true, maxRetries: 2, initialIntervalSeconds: 5 }, + waitForReset: { enabled: true, maxWaitSeconds: 300 }, + onExhausted: "skip", + }, + hard: { + retry: { enabled: false, maxRetries: 0, initialIntervalSeconds: 0 }, + waitForReset: { enabled: false, maxWaitSeconds: 0 }, + onExhausted: "stop", + }, + }, + }, + ], + }); + }); + + it("normalizes legacy dynamic rules into both error classes", () => { + const result = normalizeAgentProfile({ + id: "dynamic-profile", + kind: "dynamic", + dynamic: { + version: 1, + candidates: [ + { + position: 0, + execution_profile_id: "primary", + enabled: true, + rules: { on_provider_error: "retry_same", quota_limited: "stop" }, + }, + ], + }, + }); + const candidate = result.dynamic?.candidates[0]; + expect(candidate?.policies.transient.retry.maxRetries).toBe(1); + expect(candidate?.policies.hard.onExhausted).toBe("stop"); + }); }); describe("toAgentProfilePayload", () => { @@ -195,3 +271,58 @@ describe("toAgentProfilePayload", () => { expect("enabled" in omitted).toBe(false); }); }); + +describe("toAgentProfilePayload dynamic candidates", () => { + it("serializes dynamic candidates with opaque profile IDs", () => { + const payload = toAgentProfilePayload({ + id: toAgentProfileId("dynamic-profile"), + kind: "dynamic", + dynamic: { + version: 2, + candidates: [ + { + position: 0, + executionProfileId: toAgentProfileId("primary"), + enabled: true, + policies: { + version: 1, + transient: { + retry: { enabled: true, maxRetries: 1, initialIntervalSeconds: 5 }, + waitForReset: { enabled: false, maxWaitSeconds: 0 }, + onExhausted: "stop", + }, + hard: { + retry: { enabled: false, maxRetries: 0, initialIntervalSeconds: 0 }, + waitForReset: { enabled: false, maxWaitSeconds: 0 }, + onExhausted: "skip", + }, + }, + }, + ], + }, + }); + expect(payload.dynamic).toEqual({ + version: 2, + candidates: [ + { + position: 0, + execution_profile_id: "primary", + enabled: true, + policies: { + version: 1, + transient: { + retry: { enabled: true, max_retries: 1, initial_interval_seconds: 5 }, + wait_for_reset: { enabled: false, max_wait_seconds: 0 }, + on_exhausted: "stop", + }, + hard: { + retry: { enabled: false, max_retries: 0, initial_interval_seconds: 0 }, + wait_for_reset: { enabled: false, max_wait_seconds: 0 }, + on_exhausted: "skip", + }, + }, + }, + ], + }); + }); +}); diff --git a/apps/web/lib/api/domains/agent-profile-normalize.ts b/apps/web/lib/api/domains/agent-profile-normalize.ts index 273412f01e..9d86d0f7d5 100644 --- a/apps/web/lib/api/domains/agent-profile-normalize.ts +++ b/apps/web/lib/api/domains/agent-profile-normalize.ts @@ -7,7 +7,16 @@ // `app/actions/agents.ts` and `lib/ws/handlers/agents.ts`. import type { ProfileEnvVar } from "@/lib/types/http"; -import type { AgentProfile, AgentProfilePayload, CLIFlag } from "@/lib/types/agent-profile"; +import type { + AgentProfile, + DynamicAgentPolicy, + AgentProfileKind, + AgentProfilePayload, + CLIFlag, + DynamicErrorPolicy, + DynamicPolicyOutcome, + DynamicAgentProfile, +} from "@/lib/types/agent-profile"; import { agentProfileId, workspaceId as toWorkspaceId } from "@/lib/types/ids"; type RawProfile = Partial & Partial & Record; @@ -47,6 +56,156 @@ function pickConfigOptions(raw: RawProfile): Record | undefined return entries.length > 0 ? Object.fromEntries(entries) : undefined; } +const TRANSIENT_CODES = new Set([ + "network_unavailable", + "provider_unavailable", + "provider_overloaded", + "model_capacity", + "rate_limited", + "agent_transport_lost", +]); + +const HARD_CODES = new Set([ + "auth_required", + "missing_credentials", + "subscription_required", + "quota_limited", + "model_unavailable", + "provider_not_configured", +]); + +function defaultDynamicErrorPolicy(): DynamicErrorPolicy { + return { + retry: { enabled: false, maxRetries: 0, initialIntervalSeconds: 0 }, + waitForReset: { enabled: false, maxWaitSeconds: 0 }, + onExhausted: "skip", + }; +} + +function policyForLegacyAction(action: string): DynamicErrorPolicy { + const policy = defaultDynamicErrorPolicy(); + if (action === "retry_same") { + policy.retry = { enabled: true, maxRetries: 1, initialIntervalSeconds: 5 }; + policy.onExhausted = "stop"; + } else if (action === "stop") { + policy.onExhausted = "stop"; + } + return policy; +} + +function legacyRulesToPolicy(rules: Record): DynamicAgentPolicy { + const generic = rules.on_provider_error; + const result: DynamicAgentPolicy = { + version: 1, + transient: policyForLegacyAction(generic ?? "try_next"), + hard: policyForLegacyAction(generic ?? "try_next"), + }; + for (const [code, action] of Object.entries(rules)) { + if (code === "on_provider_error") continue; + if (TRANSIENT_CODES.has(code)) result.transient = policyForLegacyAction(action); + if (HARD_CODES.has(code)) result.hard = policyForLegacyAction(action); + } + return result; +} + +function objectValue(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function numberValue(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function normalizeDynamicRetryPolicy(source: Record): DynamicErrorPolicy["retry"] { + const retry = objectValue(source.retry) ?? {}; + const retrySnake = objectValue(source.retry_policy) ?? {}; + return { + enabled: (retry.enabled ?? retrySnake.enabled) === true, + maxRetries: numberValue( + retry.maxRetries ?? retry.max_retries ?? retrySnake.maxRetries ?? retrySnake.max_retries, + 0, + ), + initialIntervalSeconds: numberValue( + retry.initialIntervalSeconds ?? + retry.initial_interval_seconds ?? + retrySnake.initialIntervalSeconds ?? + retrySnake.initial_interval_seconds, + 0, + ), + }; +} + +function normalizeDynamicWaitPolicy( + source: Record, +): DynamicErrorPolicy["waitForReset"] { + const wait = objectValue(source.waitForReset) ?? objectValue(source.wait_for_reset) ?? {}; + return { + enabled: wait.enabled === true, + maxWaitSeconds: numberValue(wait.maxWaitSeconds ?? wait.max_wait_seconds, 0), + }; +} + +function normalizeDynamicErrorPolicy(raw: unknown): DynamicErrorPolicy { + const source = objectValue(raw) ?? {}; + const outcome = source.onExhausted ?? source.on_exhausted; + return { + retry: normalizeDynamicRetryPolicy(source), + waitForReset: normalizeDynamicWaitPolicy(source), + onExhausted: outcome === "stop" ? "stop" : "skip", + }; +} + +function normalizeDynamicPolicy( + raw: unknown, + legacyRules: Record, +): DynamicAgentPolicy { + const source = objectValue(raw); + if (!source) return legacyRulesToPolicy(legacyRules); + return { + version: numberValue(source.version, 1), + transient: normalizeDynamicErrorPolicy(source.transient), + hard: normalizeDynamicErrorPolicy(source.hard), + }; +} + +function pickDynamic(raw: RawProfile): DynamicAgentProfile | undefined { + const value = raw.dynamic; + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const document = value as Record; + const version = typeof document.version === "number" ? document.version : 0; + const candidates = Array.isArray(document.candidates) ? document.candidates : []; + return { + version, + candidates: candidates.flatMap((candidate) => { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return []; + const item = candidate as Record; + const executionProfileId = item.executionProfileId ?? item.execution_profile_id; + const position = item.position; + if (typeof executionProfileId !== "string" || typeof position !== "number") return []; + const rules = item.rules; + const legacyRules = + rules && typeof rules === "object" && !Array.isArray(rules) + ? Object.fromEntries( + Object.entries(rules).filter( + (entry): entry is [string, string] => + typeof entry[0] === "string" && typeof entry[1] === "string", + ), + ) + : {}; + return [ + { + position, + executionProfileId: agentProfileId(executionProfileId), + enabled: typeof item.enabled === "boolean" ? item.enabled : true, + policies: normalizeDynamicPolicy(item.policies ?? item.policy, legacyRules), + }, + ]; + }), + }; +} + /** * Convert a kanban snake_case payload (or a partially-camelCased one) into * the canonical `AgentProfile`. Office-orchestration fields are left @@ -54,8 +213,11 @@ function pickConfigOptions(raw: RawProfile): Record | undefined */ export function normalizeAgentProfile(raw: unknown): AgentProfile { const profile = (raw ?? {}) as RawProfile; + const kind = pickString(profile, "kind", "kind"); + const dynamic = pickDynamic(profile); return { id: agentProfileId(pickString(profile, "id", "id")), + ...(kind === "dynamic" || kind === "concrete" ? { kind: kind as AgentProfileKind } : {}), name: pickString(profile, "name", "name"), agentId: pickString(profile, "agentId", "agent_id"), agentDisplayName: pickString(profile, "agentDisplayName", "agent_display_name"), @@ -79,6 +241,7 @@ export function normalizeAgentProfile(raw: unknown): AgentProfile { userModified: (profile.userModified ?? profile.user_modified) as boolean | undefined, createdAt: pickString(profile, "createdAt", "created_at"), updatedAt: pickString(profile, "updatedAt", "updated_at"), + ...(dynamic ? { dynamic } : {}), }; } @@ -101,6 +264,7 @@ export function toAgentProfilePayload( ): Partial { const payload: Partial = {}; setPayloadField(payload, "id", profile.id); + setPayloadField(payload, "kind", profile.kind); setPayloadField(payload, "name", profile.name); setPayloadField(payload, "agent_id", profile.agentId); setPayloadField(payload, "agent_display_name", profile.agentDisplayName); @@ -119,5 +283,45 @@ export function toAgentProfilePayload( setPayloadField(payload, "user_modified", profile.userModified); setPayloadField(payload, "created_at", profile.createdAt); setPayloadField(payload, "updated_at", profile.updatedAt); + if (profile.dynamic) { + payload.dynamic = { + version: profile.dynamic.version, + candidates: profile.dynamic.candidates.map((candidate) => { + const policy = candidate.policies ?? legacyRulesToPolicy(candidate.rules ?? {}); + return { + position: candidate.position, + execution_profile_id: candidate.executionProfileId, + enabled: candidate.enabled, + policies: { + version: policy.version, + transient: { + retry: { + enabled: policy.transient.retry.enabled, + max_retries: policy.transient.retry.maxRetries, + initial_interval_seconds: policy.transient.retry.initialIntervalSeconds, + }, + wait_for_reset: { + enabled: policy.transient.waitForReset.enabled, + max_wait_seconds: policy.transient.waitForReset.maxWaitSeconds, + }, + on_exhausted: policy.transient.onExhausted as DynamicPolicyOutcome, + }, + hard: { + retry: { + enabled: policy.hard.retry.enabled, + max_retries: policy.hard.retry.maxRetries, + initial_interval_seconds: policy.hard.retry.initialIntervalSeconds, + }, + wait_for_reset: { + enabled: policy.hard.waitForReset.enabled, + max_wait_seconds: policy.hard.waitForReset.maxWaitSeconds, + }, + on_exhausted: policy.hard.onExhausted as DynamicPolicyOutcome, + }, + }, + }; + }), + }; + } return payload; } diff --git a/apps/web/lib/settings/agent-display-order.test.ts b/apps/web/lib/settings/agent-display-order.test.ts index 80c872065a..863c5e0ad8 100644 --- a/apps/web/lib/settings/agent-display-order.test.ts +++ b/apps/web/lib/settings/agent-display-order.test.ts @@ -38,6 +38,14 @@ describe("orphanedAgents", () => { expect(orphanedAgents(detectedAgents(DISCOVERY), saved)).toEqual([]); }); + + it("keeps the empty virtual Dynamic family visible for profile creation", () => { + const saved = [{ name: "dynamic", profiles: [] }]; + + expect(orphanedAgents(detectedAgents(DISCOVERY), saved).map((agent) => agent.name)).toEqual([ + "dynamic", + ]); + }); }); describe("orderAgentsForDisplay", () => { diff --git a/apps/web/lib/settings/agent-display-order.ts b/apps/web/lib/settings/agent-display-order.ts index 2286fdf8fc..cc645b3a9e 100644 --- a/apps/web/lib/settings/agent-display-order.ts +++ b/apps/web/lib/settings/agent-display-order.ts @@ -16,14 +16,18 @@ export type DiscoveredAgent = { name: string; available: boolean }; export type SavedAgent = { name: string; profiles: ReadonlyArray }; +/** The built-in virtual family has no host installation to appear in discovery. */ +export const DYNAMIC_AGENT_NAME = "dynamic"; + /** Agents the scan currently detects, in rank order. */ export function detectedAgents(discovery: ReadonlyArray): D[] { return discovery.filter((agent) => agent.available); } /** - * Configured agents the scan no longer detects, in rank order. Only ones with - * profiles: an agent with nothing configured under it has nothing to preserve. + * Configured agents the scan no longer detects, in rank order. Empty concrete + * agent families have nothing to preserve, but the virtual Dynamic family is + * always retained so settings can create its first profile. * * The Agents page still renders these — via a synthetic discovery record — so a * CLI going missing never hides the profiles configured against it. @@ -33,7 +37,11 @@ export function orphanedAgents( saved: ReadonlyArray, ): S[] { const detectedNames = new Set(detected.map((agent) => agent.name)); - return saved.filter((agent) => agent.profiles.length > 0 && !detectedNames.has(agent.name)); + return saved.filter( + (agent) => + (agent.profiles.length > 0 || agent.name === DYNAMIC_AGENT_NAME) && + !detectedNames.has(agent.name), + ); } /** diff --git a/apps/web/lib/state/slices/features/features-contract.test.ts b/apps/web/lib/state/slices/features/features-contract.test.ts index a6ac2caa3a..202ff62285 100644 --- a/apps/web/lib/state/slices/features/features-contract.test.ts +++ b/apps/web/lib/state/slices/features/features-contract.test.ts @@ -16,6 +16,10 @@ function backendFeatureKeys(source: string): string[] { } describe("feature flag repository contract", () => { + it("keeps dynamic agent routing disabled by default", () => { + expect(defaultFeatureFlags.dynamicAgentRouting).toBe(false); + }); + it("keeps frontend defaults equal to backend FeaturesConfig JSON keys", async () => { const backendConfig = await readFile(backendConfigPath, "utf8"); diff --git a/apps/web/lib/state/slices/features/types.ts b/apps/web/lib/state/slices/features/types.ts index 4cef4e825a..9452d0c277 100644 --- a/apps/web/lib/state/slices/features/types.ts +++ b/apps/web/lib/state/slices/features/types.ts @@ -9,6 +9,7 @@ export const defaultFeatureFlags = { // overwrites this with the backend's effective values after startup. office: false, auth: false, + dynamicAgentRouting: false, claudeBackgroundPromptHandoff: false, claudeMidTurnSteering: false, } as const; diff --git a/apps/web/lib/state/slices/session/session-slice.ts b/apps/web/lib/state/slices/session/session-slice.ts index 822a47f752..0ef4a03d60 100644 --- a/apps/web/lib/state/slices/session/session-slice.ts +++ b/apps/web/lib/state/slices/session/session-slice.ts @@ -1,4 +1,4 @@ -/* eslint-disable max-lines -- slice file is one line over the 600 cap after adding the isLoadingMore metadata flag; splitting the slice would orphan its actions from their state type */ +/* eslint-disable max-lines -- session state intentionally keeps its coordinated actions together. */ import type { StateCreator } from "zustand"; import { original } from "immer"; import type { Message, TaskSession } from "@/lib/types/http"; @@ -152,11 +152,31 @@ function mergeCancellationProjection( /** Merge an incoming session update with an existing session, preserving nullable fields. */ function mergeTaskSession(existing: TaskSession, incoming: TaskSession): TaskSession { const cancellation = mergeCancellationProjection(existing, incoming); + const incomingRouteGeneration = incoming.route_generation; + const existingRouteGeneration = existing.route_generation; + const routeIsStale = + existingRouteGeneration !== undefined && + (incomingRouteGeneration === undefined || incomingRouteGeneration < existingRouteGeneration); const pendingAction = mergePendingActionProjection(existing, incoming); return { ...existing, ...incoming, ...cancellation, + ...(routeIsStale + ? { + execution_profile_id: existing.execution_profile_id, + route_generation: existing.route_generation, + route_state: existing.route_state, + route_reason: existing.route_reason, + route_error_code: existing.route_error_code, + route_error_class: existing.route_error_class, + route_catalogue_version: existing.route_catalogue_version, + route_retry_ordinal: existing.route_retry_ordinal, + route_deadline: existing.route_deadline, + route_pending_outcome: existing.route_pending_outcome, + downstream_acp_session_id: existing.downstream_acp_session_id, + } + : {}), ...pendingAction, agent_profile_snapshot: incoming.agent_profile_snapshot ?? existing.agent_profile_snapshot, worktree_id: incoming.worktree_id ?? existing.worktree_id, diff --git a/apps/web/lib/state/slices/settings/types.test.ts b/apps/web/lib/state/slices/settings/types.test.ts index ab106025ef..6328660fc3 100644 --- a/apps/web/lib/state/slices/settings/types.test.ts +++ b/apps/web/lib/state/slices/settings/types.test.ts @@ -10,6 +10,12 @@ describe("isSelectableAgentProfile", () => { ])("treats %s profiles correctly", (_label, enabled, expected) => { expect(isSelectableAgentProfile({ enabled })).toBe(expected); }); + + it("excludes dynamic profiles when dynamic routing is disabled", () => { + expect(isSelectableAgentProfile({ enabled: true, kind: "dynamic" }, false)).toBe(false); + expect(isSelectableAgentProfile({ enabled: true, kind: "dynamic" }, true)).toBe(true); + expect(isSelectableAgentProfile({ enabled: true, kind: "concrete" }, false)).toBe(true); + }); }); describe("toAgentProfileOption", () => { @@ -37,6 +43,16 @@ describe("toAgentProfileOption", () => { expect(legacy.enabled).toBe(true); }); + it("preserves profile kind for picker filtering", () => { + const option = toAgentProfileOption(agent, { + id: agentProfileId("dynamic"), + agentDisplayName: "Dynamic", + name: "Frontier", + kind: "dynamic", + }); + expect(option.kind).toBe("dynamic"); + }); + it("preserves the owning agent capability fields", () => { const option = toAgentProfileOption( { diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index e546b314bf..9b1c540ca7 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -21,6 +21,7 @@ import type { SecretListItem } from "@/lib/types/http-secrets"; import type { SpritesStatus, SpritesInstance } from "@/lib/types/http-sprites"; import type { TasksListGroup, TasksListSort } from "@/lib/tasks/tasks-list-options"; import type { SleepInhibitionResponse } from "@/lib/types/system"; +import type { AgentProfileKind } from "@/lib/types/agent-profile"; export type ExecutorsState = { items: Executor[]; @@ -48,6 +49,7 @@ export type AgentProfileOption = { label: string; agent_id: string; agent_name: string; + kind?: AgentProfileKind; cli_passthrough: boolean; /** Configured start model (ACP model ID). Empty = agent default. */ model?: string; @@ -74,8 +76,11 @@ export type AgentProfileOption = { }; /** Profiles with an omitted enabled field remain selectable for compatibility. */ -export function isSelectableAgentProfile(profile: Pick): boolean { - return profile.enabled !== false; +export function isSelectableAgentProfile( + profile: Pick, + dynamicRoutingEnabled = true, +): boolean { + return profile.enabled !== false && (dynamicRoutingEnabled || profile.kind !== "dynamic"); } /** @@ -253,6 +258,7 @@ export function toAgentProfileOption( agent: Pick, profile: Pick & { updatedAt?: string; + kind?: AgentProfileKind; cliPassthrough?: boolean; model?: string; fallbackModel?: string; @@ -265,6 +271,7 @@ export function toAgentProfileOption( label: `${profile.agentDisplayName ?? ""} • ${profile.name}`, agent_id: agent.id, agent_name: agent.name, + kind: profile.kind, cli_passthrough: profile.cliPassthrough ?? false, model: profile.model ?? undefined, fallback_model: profile.fallbackModel ?? undefined, diff --git a/apps/web/lib/types/agent-profile.ts b/apps/web/lib/types/agent-profile.ts index 039cba0ee2..2abe59eddb 100644 --- a/apps/web/lib/types/agent-profile.ts +++ b/apps/web/lib/types/agent-profile.ts @@ -28,6 +28,48 @@ export type CLIFlag = { export type BillingType = "api_key" | "subscription"; +export type AgentProfileKind = "concrete" | "dynamic"; + +export type DynamicErrorClass = "transient" | "hard"; +export type DynamicPolicyOutcome = "skip" | "stop"; + +export type DynamicRetryPolicy = { + enabled: boolean; + maxRetries: number; + initialIntervalSeconds: number; +}; + +export type DynamicResetWaitPolicy = { + enabled: boolean; + maxWaitSeconds: number; +}; + +export type DynamicErrorPolicy = { + retry: DynamicRetryPolicy; + waitForReset: DynamicResetWaitPolicy; + onExhausted: DynamicPolicyOutcome; +}; + +export type DynamicAgentPolicy = { + version: number; + transient: DynamicErrorPolicy; + hard: DynamicErrorPolicy; +}; + +export type DynamicAgentCandidate = { + position: number; + executionProfileId: AgentProfileId; + enabled: boolean; + policies: DynamicAgentPolicy; + /** Read compatibility for clients that still construct legacy drafts. */ + rules?: Record; +}; + +export type DynamicAgentProfile = { + version: number; + candidates: DynamicAgentCandidate[]; +}; + export type UtilizationWindow = { label: string; utilization_pct: number; @@ -54,6 +96,10 @@ export type AgentStatus = "idle" | "working" | "paused" | "stopped" | "pending_a export type AgentProfile = { // --- Identity --- id: AgentProfileId; + /** The execution family. Dynamic profiles own routing, not a subprocess. */ + kind?: AgentProfileKind; + /** Versioned routing document for the dynamic execution family. */ + dynamic?: DynamicAgentProfile; name: string; /** ID of the agent (CLI) this profile belongs to. */ agentId: string; @@ -159,6 +205,7 @@ export type OfficeAgentProfile = AgentProfile & export type AgentProfilePayload = { id: string; agent_id: string; + kind?: AgentProfileKind; name: string; agent_display_name: string; model: string; @@ -176,4 +223,40 @@ export type AgentProfilePayload = { user_modified?: boolean; created_at: string; updated_at: string; + dynamic?: { + version: number; + candidates: Array<{ + position: number; + execution_profile_id: string; + enabled: boolean; + policies?: { + version: number; + transient: { + retry: { + enabled: boolean; + max_retries: number; + initial_interval_seconds: number; + }; + wait_for_reset: { + enabled: boolean; + max_wait_seconds: number; + }; + on_exhausted: DynamicPolicyOutcome; + }; + hard: { + retry: { + enabled: boolean; + max_retries: number; + initial_interval_seconds: number; + }; + wait_for_reset: { + enabled: boolean; + max_wait_seconds: number; + }; + on_exhausted: DynamicPolicyOutcome; + }; + }; + rules?: Record; + }>; + }; }; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 3005b282ce..4851b59a11 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -104,6 +104,11 @@ export type StepDefinition = { is_start_step?: boolean; show_in_command_panel?: boolean; agent_profile_id?: AgentProfileId; + execution_profile_id?: AgentProfileId; + route_generation?: number; + route_state?: string; + route_reason?: string; + downstream_acp_session_id?: string; auto_advance_requires_signal?: boolean; cancel_triggers_turn_complete?: boolean; wip_limit?: number; @@ -473,6 +478,23 @@ export type TaskSession = ActiveSubagentCountFields & { /** Optional user-supplied label shown on the session tab. */ name?: string; agent_profile_id?: AgentProfileId; + /** Logical profile selected by the user; dynamic profiles resolve this to a concrete launch profile. */ + execution_profile_id?: AgentProfileId; + /** Monotonic dynamic-route generation used for stale action rejection. */ + route_generation?: number; + /** Durable dynamic-route state, such as starting, waiting, or action_required. */ + route_state?: string; + /** Stable reason code for the current dynamic-route state. */ + route_reason?: string; + /** Classified provider cause currently driving route recovery. */ + route_error_code?: string; + route_error_class?: "transient" | "hard" | "unclassified" | string; + route_catalogue_version?: string; + route_retry_ordinal?: number; + route_deadline?: string; + route_pending_outcome?: "skip" | "stop" | string; + /** Downstream ACP session ID for the currently selected concrete candidate. */ + downstream_acp_session_id?: string; container_id?: string; executor_id?: string; environment_id?: string; @@ -793,6 +815,8 @@ export type Turn = { task_id: TaskId; started_at: string; completed_at?: string; + execution_profile_id?: AgentProfileId; + route_generation?: number; metadata?: Record; created_at: string; updated_at: string; diff --git a/apps/web/lib/types/session-events.ts b/apps/web/lib/types/session-events.ts index 1bdc143a85..026175cad7 100644 --- a/apps/web/lib/types/session-events.ts +++ b/apps/web/lib/types/session-events.ts @@ -49,6 +49,17 @@ export type TaskSessionStateChangedPayload = { */ agent_profile_id?: string; agent_profile_snapshot?: Record; + execution_profile_id?: string; + route_generation?: number; + route_state?: string; + route_reason?: string; + route_error_code?: string; + route_error_class?: "transient" | "hard" | "unclassified" | string; + route_catalogue_version?: string; + route_retry_ordinal?: number; + route_deadline?: string; + route_pending_outcome?: "skip" | "stop" | string; + downstream_acp_session_id?: string; metadata?: Record; session_metadata?: Record; is_passthrough?: boolean; @@ -120,6 +131,8 @@ export type TurnEventPayload = { task_id: string; started_at: string; completed_at?: string; + execution_profile_id?: string; + route_generation?: number; metadata?: Record; /** Whether the completed turn produced any agent output. Only set on turn.completed. */ had_output?: boolean; diff --git a/apps/web/lib/ws/handlers/agent-session-route-fields.ts b/apps/web/lib/ws/handlers/agent-session-route-fields.ts new file mode 100644 index 0000000000..6317fa848e --- /dev/null +++ b/apps/web/lib/ws/handlers/agent-session-route-fields.ts @@ -0,0 +1,8 @@ +export const ROUTE_SESSION_FIELDS = [ + "route_error_code", + "route_error_class", + "route_catalogue_version", + "route_retry_ordinal", + "route_deadline", + "route_pending_outcome", +] as const; diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index 8f6fd8bf2d..f49a8d8a4a 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -1,4 +1,4 @@ -/* eslint-disable max-lines -- comprehensive session-state handler; merge with main pushed it past 600. */ +/* eslint-disable max-lines -- session WebSocket handlers share one event registry. */ import type { StoreApi } from "zustand"; import { createDebugLogger } from "@/lib/debug/log"; import type { AppState } from "@/lib/state/store"; @@ -14,6 +14,7 @@ import { import type { QueueStatusChangedPayload } from "@/lib/types/backend"; import { syncKanbanPrimarySessionState } from "@/lib/ws/handlers/agent-session-kanban-sync"; import { parseContextWindowEntry } from "@/lib/state/slices/session-runtime/context-window"; +import { ROUTE_SESSION_FIELDS } from "@/lib/ws/handlers/agent-session-route-fields"; import { t } from "@/lib/i18n"; import { maybeMarkQuickChatUnseenIdle } from "@/lib/ws/handlers/quick-chat-unseen"; import { readLastAgentError } from "@/lib/session-last-agent-error"; @@ -234,6 +235,12 @@ const CARRIED_WHEN_DEFINED = [ "supports_steering", "cancellation_pending", "cancellation_revision", + "execution_profile_id", + "route_generation", + "route_state", + "route_reason", + ...ROUTE_SESSION_FIELDS, + "downstream_acp_session_id", ] as const; /** Copy each CARRIED_WHEN_DEFINED field onto `update` only when the payload defines it. */ diff --git a/apps/web/lib/ws/handlers/turns.ts b/apps/web/lib/ws/handlers/turns.ts index 962c2da060..aa06eaea9b 100644 --- a/apps/web/lib/ws/handlers/turns.ts +++ b/apps/web/lib/ws/handlers/turns.ts @@ -3,7 +3,7 @@ import { createDebugLogger } from "@/lib/debug/log"; import { parseTurnTimestamp } from "@/lib/state/slices/session/turn-actions"; import type { AppState } from "@/lib/state/store"; import type { WsHandlers } from "@/lib/ws/handlers/types"; -import { sessionId, taskId } from "@/lib/types/http"; +import { agentProfileId, sessionId, taskId } from "@/lib/types/http"; import { maybeEmitEmptyTurnNotice } from "@/lib/ws/handlers/empty-turn-notice"; import type { MessageUpdateScheduler } from "@/lib/ws/handlers/messages"; @@ -48,6 +48,10 @@ export function registerTurnsHandlers( task_id: taskId(payload.task_id), started_at: payload.started_at, completed_at: payload.completed_at, + execution_profile_id: payload.execution_profile_id + ? agentProfileId(payload.execution_profile_id) + : undefined, + route_generation: payload.route_generation, metadata: payload.metadata, created_at: payload.created_at, updated_at: payload.updated_at, @@ -89,6 +93,10 @@ export function registerTurnsHandlers( task_id: taskId(payload.task_id), started_at: payload.started_at, completed_at: payload.completed_at || new Date().toISOString(), + execution_profile_id: payload.execution_profile_id + ? agentProfileId(payload.execution_profile_id) + : undefined, + route_generation: payload.route_generation, metadata: payload.metadata, created_at: payload.created_at, updated_at: payload.updated_at, diff --git a/apps/web/src/locales/en/agents.json b/apps/web/src/locales/en/agents.json index 22d5c8020f..0491b3b62b 100644 --- a/apps/web/src/locales/en/agents.json +++ b/apps/web/src/locales/en/agents.json @@ -232,8 +232,57 @@ "duplicateProfileSuccess": "Profile duplicated", "duplicateSaveBlocked": "Settings are still saving. Try again in a moment.", "failedToDuplicateProfile": "Failed to duplicate profile", + "dynamicAgents": "Dynamic agents", + "dynamicAgentsDescription": "Route one logical profile through an ordered list of concrete agent profiles.", + "dynamicProfileDisabled": "Dynamic agent routing is disabled.", + "dynamicProfileDescription": "Choose an ordered list of concrete profiles for fallback execution.", + "dynamicProfileSettings": "Dynamic profile settings", + "dynamicCandidates": "Candidates", + "dynamicCandidatesDescription": "Candidates run in order. The next candidate is used only when the configured error action allows it.", + "dynamicRoutingPolicyTitle": "When Kandev switches candidates", + "dynamicRoutingPolicySwitchable": "Switching can follow classified provider failures: authentication or subscription problems, missing credentials or configuration, quota or rate limits, unavailable models, capacity, network, and provider availability or overload.", + "dynamicRoutingPolicyExcluded": "Kandev does not switch for task, repository, permission, tool or runtime errors, transport loss, or ambiguous mid-turn results. Those cases stop for manual recovery to avoid repeating work.", + "dynamicRoutingPolicyRecovery": "After a switch, the failed provider is paused for 1 minute, or until its reset time when provided. Kandev then runs an exclusive health probe before using it again.", + "dynamicPolicyWaitExplanation": "Reset waiting uses a trusted future reset time once per candidate and error class, only when it is within your maximum. After that wait, Kandev makes one new attempt before applying the retry policy.", + "dynamicTransientErrors": "Transient errors", + "dynamicTransientErrorsDescription": "Temporary conditions such as capacity, rate limits, network failures, timeouts, provider outages, and transport interruptions.", + "dynamicHardErrors": "Hard errors", + "dynamicHardErrorsDescription": "Account or configuration conditions such as subscription limits, exhausted credits, authentication, missing credentials, unavailable models, or provider configuration.", + "dynamicPolicyRetry": "Retry this candidate", + "dynamicPolicyWaitForReset": "Wait for a provider reset", + "dynamicPolicyAfterExhausted": "After recovery is exhausted", + "dynamicPolicyMaxRetries": "Maximum retries", + "dynamicPolicyInitialInterval": "Initial interval (seconds)", + "dynamicPolicyMaxWait": "Maximum reset wait (seconds)", + "dynamicPolicyValidation": "Fix the highlighted policy values before saving.", + "dynamicPolicyMaxRetriesValidation": "Enter between 1 and 10 retries.", + "dynamicPolicyInitialIntervalValidation": "Enter an initial interval between 1 second and 1 hour.", + "dynamicPolicyMaxWaitValidation": "Enter a reset wait between 1 second and 7 days.", + "dynamicPolicySchedule": "Retry up to {{retries}} times. The first retry waits {{first}} seconds, then the interval doubles.", + "dynamicPolicyNoRetry": "No same-candidate retries. Kandev applies the selected outcome immediately.", + "dynamicPolicyWaitDescription": "Use a trusted future reset or retry date when it is within the maximum wait.", + "dynamicPolicySkip": "Skip candidate", + "dynamicPolicyStop": "Stop for manual recovery", + "dynamicPolicyRetryHelp": "Retries the same candidate with exponential backoff before the selected outcome.", + "dynamicPolicyWaitHelp": "Waits for a trusted future provider reset or retry date. The wait is bounded and used once per candidate and class.", + "dynamicPolicyOutcomeHelp": "Controls what Kandev does after reset waiting and all same-candidate retries are exhausted.", + "dynamicPolicyOptionInfo": "Explain {{option}} policy", + "addDynamicCandidate": "Add candidate", + "noDynamicCandidates": "Add at least one concrete profile.", + "moveDynamicCandidateUp": "Move candidate up", + "moveDynamicCandidateDown": "Move candidate down", + "removeDynamicCandidate": "Remove candidate", + "dynamicCandidatePickerDescription": "Choose a concrete profile that is eligible for dynamic routing.", + "searchDynamicCandidates": "Search agent profiles...", + "noDynamicCandidatesFound": "No eligible agent profiles found.", + "dynamicRouteAction": "On provider error", + "dynamicRouteActionInfo": "Explain {{action}} routing behavior", + "dynamicRouteRetryDescription": "Retry the same candidate after a provider error. Use this when a temporary failure may clear.", + "dynamicRouteTryNextDescription": "Skip this candidate for the current turn and continue with the next eligible profile.", + "dynamicRouteStopDescription": "Stop routing after this candidate fails. The task waits for a manual recovery action.", + "dynamicProfileSaved": "Dynamic profile saved.", + "savingProfile": "Saving...", "saveProfile": "Save profile", "createProfile": "Create profile", - "savingProfile": "Saving...", "creatingProfile": "Creating..." } diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index 2512fa199a..8a01ca933b 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -1692,6 +1692,27 @@ "sessionActionSetPrimary": "Set primary", "sessionActionStop": "Stopping session", "sessionActionSuccessful": "{{action}} successful", + "dynamicRouteWaiting": "No eligible agent is available.", + "dynamicRouteActionRequired": "This route needs a recovery action.", + "dynamicRouteRetry": "Retry current agent", + "dynamicRouteRetryNow": "Retry now", + "dynamicRouteTryNext": "Try next agent", + "dynamicRouteSkipNow": "Skip now", + "dynamicRouteRetrying": "Retrying agent...", + "dynamicRouteTryNexting": "Trying next agent...", + "dynamicRouteSkipping": "Skipping agent...", + "dynamicRouteCancelWait": "Cancel wait", + "dynamicRouteStop": "Stop", + "dynamicRouteReasonCandidateOrder": "Selected by profile order", + "dynamicRouteReasonNoEligibleCandidate": "No eligible candidate", + "dynamicRouteReasonProviderUnavailable": "Provider unavailable", + "dynamicRouteReasonQuotaLimited": "Provider usage limit", + "dynamicRouteErrorClass": "Error class: {{class}}", + "dynamicRouteErrorCode": "Error code: {{code}}", + "dynamicRouteRetryOrdinal_one": "Retry {{count}}", + "dynamicRouteRetryOrdinal_other": "Retry {{count}}", + "dynamicRouteDeadline": "Next recovery attempt: {{deadline}}", + "dynamicRoutePendingOutcome": "After recovery: {{outcome}}", "sessionCompleted": "This session is complete.", "composerPlaceholderRecovery": "Choose a recovery option above to continue...", "composerPlaceholderClarification": "Queue instructions while the question is pending...", diff --git a/apps/web/src/locales/pseudo/agents.json b/apps/web/src/locales/pseudo/agents.json index 071be73dc0..b770254d65 100644 --- a/apps/web/src/locales/pseudo/agents.json +++ b/apps/web/src/locales/pseudo/agents.json @@ -232,8 +232,57 @@ "duplicateProfileSuccess": "Ƥŕōƒĩĺē ďũƥĺĩćàţēď", "duplicateSaveBlocked": "Śēţţĩńĝś àŕē śţĩĺĺ śàvĩńĝ. Ţŕŷ àĝàĩń ĩń à ḿōḿēńţ.", "failedToDuplicateProfile": "Ƒàĩĺēď ţō ďũƥĺĩćàţē ƥŕōƒĩĺē", + "dynamicAgents": "Ďŷńàḿĩć àĝēńţś", + "dynamicAgentsDescription": "Ŕōũţē ōńē ĺōĝĩćàĺ ƥŕōƒĩĺē ţĥŕōũĝĥ àń ōŕďēŕēď ĺĩśţ ōƒ ćōńćŕēţē àĝēńţ ƥŕōƒĩĺēś.", + "dynamicProfileDisabled": "Ďŷńàḿĩć àĝēńţ ŕōũţĩńĝ ĩś ďĩśàƀĺēď.", + "dynamicProfileDescription": "Ćĥōōśē àń ōŕďēŕēď ĺĩśţ ōƒ ćōńćŕēţē ƥŕōƒĩĺēś ƒōŕ ƒàĺĺƀàćķ ēxēćũţĩōń.", + "dynamicProfileSettings": "Ďŷńàḿĩć ƥŕōƒĩĺē śēţţĩńĝś", + "dynamicCandidates": "Ćàńďĩďàţēś", + "dynamicCandidatesDescription": "Ćàńďĩďàţēś ŕũń ĩń ōŕďēŕ. Ţĥē ńēxţ ćàńďĩďàţē ĩś ũśēď ōńĺŷ ŵĥēń ţĥē ćōńƒĩĝũŕēď ēŕŕōŕ àćţĩōń àĺĺōŵś ĩţ.", + "dynamicRoutingPolicyTitle": "Ŵĥēń Ķàńďēv śŵĩţćĥēś ćàńďĩďàţēś", + "dynamicRoutingPolicySwitchable": "Śŵĩţćĥĩńĝ ćàń ƒōĺĺōŵ ćĺàśśĩƒĩēď ƥŕōvĩďēŕ ƒàĩĺũŕēś: àũţĥēńţĩćàţĩōń ōŕ śũƀśćŕĩƥţĩōń ƥŕōƀĺēḿś, ḿĩśśĩńĝ ćŕēďēńţĩàĺś ōŕ ćōńƒĩĝũŕàţĩōń, qũōţà ōŕ ŕàţē ĺĩḿĩţś, ũńàvàĩĺàƀĺē ḿōďēĺś, ćàƥàćĩţŷ, ńēţŵōŕķ, àńď ƥŕōvĩďēŕ àvàĩĺàƀĩĺĩţŷ ōŕ ōvēŕĺōàď.", + "dynamicRoutingPolicyExcluded": "Ķàńďēv ďōēś ńōţ śŵĩţćĥ ƒōŕ ţàśķ, ŕēƥōśĩţōŕŷ, ƥēŕḿĩśśĩōń, ţōōĺ ōŕ ŕũńţĩḿē ēŕŕōŕś, ţŕàńśƥōŕţ ĺōśś, ōŕ àḿƀĩĝũōũś ḿĩď-ţũŕń ŕēśũĺţś. Ţĥōśē ćàśēś śţōƥ ƒōŕ ḿàńũàĺ ŕēćōvēŕŷ ţō àvōĩď ŕēƥēàţĩńĝ ŵōŕķ.", + "dynamicRoutingPolicyRecovery": "Àƒţēŕ à śŵĩţćĥ, ţĥē ƒàĩĺēď ƥŕōvĩďēŕ ĩś ƥàũśēď ƒōŕ 1 ḿĩńũţē, ōŕ ũńţĩĺ ĩţś ŕēśēţ ţĩḿē ŵĥēń ƥŕōvĩďēď. Ķàńďēv ţĥēń ŕũńś àń ēxćĺũśĩvē ĥēàĺţĥ ƥŕōƀē ƀēƒōŕē ũśĩńĝ ĩţ àĝàĩń.", + "dynamicPolicyWaitExplanation": "Ŕēśēţ ŵàĩţĩńĝ ũśēś à ţŕũśţēď ƒũţũŕē ŕēśēţ ţĩḿē ōńćē ƥēŕ ćàńďĩďàţē àńď ēŕŕōŕ ćĺàśś, ōńĺŷ ŵĥēń ĩţ ĩś ŵĩţĥĩń ŷōũŕ ḿàxĩḿũḿ. Àƒţēŕ ţĥàţ ŵàĩţ, Ķàńďēv ḿàķēś ōńē ńēŵ àţţēḿƥţ ƀēƒōŕē àƥƥĺŷĩńĝ ţĥē ŕēţŕŷ ƥōĺĩćŷ.", + "dynamicTransientErrors": "Ţŕàńśĩēńţ ēŕŕōŕś", + "dynamicTransientErrorsDescription": "Ţēḿƥōŕàŕŷ ćōńďĩţĩōńś śũćĥ àś ćàƥàćĩţŷ, ŕàţē ĺĩḿĩţś, ńēţŵōŕķ ƒàĩĺũŕēś, ţĩḿēōũţś, ƥŕōvĩďēŕ ōũţàĝēś, àńď ţŕàńśƥōŕţ ĩńţēŕŕũƥţĩōńś.", + "dynamicHardErrors": "Ĥàŕď ēŕŕōŕś", + "dynamicHardErrorsDescription": "Àććōũńţ ōŕ ćōńƒĩĝũŕàţĩōń ćōńďĩţĩōńś śũćĥ àś śũƀśćŕĩƥţĩōń ĺĩḿĩţś, ēxĥàũśţēď ćŕēďĩţś, àũţĥēńţĩćàţĩōń, ḿĩśśĩńĝ ćŕēďēńţĩàĺś, ũńàvàĩĺàƀĺē ḿōďēĺś, ōŕ ƥŕōvĩďēŕ ćōńƒĩĝũŕàţĩōń.", + "dynamicPolicyRetry": "Ŕēţŕŷ ţĥĩś ćàńďĩďàţē", + "dynamicPolicyWaitForReset": "Ŵàĩţ ƒōŕ à ƥŕōvĩďēŕ ŕēśēţ", + "dynamicPolicyAfterExhausted": "Àƒţēŕ ŕēćōvēŕŷ ĩś ēxĥàũśţēď", + "dynamicPolicyMaxRetries": "Ḿàxĩḿũḿ ŕēţŕĩēś", + "dynamicPolicyInitialInterval": "Ĩńĩţĩàĺ ĩńţēŕvàĺ (śēćōńďś)", + "dynamicPolicyMaxWait": "Ḿàxĩḿũḿ ŕēśēţ ŵàĩţ (śēćōńďś)", + "dynamicPolicyValidation": "Ƒĩx ţĥē ĥĩĝĥĺĩĝĥţēď ƥōĺĩćŷ vàĺũēś ƀēƒōŕē śàvĩńĝ.", + "dynamicPolicyMaxRetriesValidation": "Ēńţēŕ ƀēţŵēēń 1 àńď 10 ŕēţŕĩēś.", + "dynamicPolicyInitialIntervalValidation": "Ēńţēŕ àń ĩńĩţĩàĺ ĩńţēŕvàĺ ƀēţŵēēń 1 śēćōńď àńď 1 ĥōũŕ.", + "dynamicPolicyMaxWaitValidation": "Ēńţēŕ à ŕēśēţ ŵàĩţ ƀēţŵēēń 1 śēćōńď àńď 7 ďàŷś.", + "dynamicPolicySchedule": "Ŕēţŕŷ ũƥ ţō {{retries}} ţĩḿēś. Ţĥē ƒĩŕśţ ŕēţŕŷ ŵàĩţś {{first}} śēćōńďś, ţĥēń ţĥē ĩńţēŕvàĺ ďōũƀĺēś.", + "dynamicPolicyNoRetry": "Ńō śàḿē-ćàńďĩďàţē ŕēţŕĩēś. Ķàńďēv àƥƥĺĩēś ţĥē śēĺēćţēď ōũţćōḿē ĩḿḿēďĩàţēĺŷ.", + "dynamicPolicyWaitDescription": "Ũśē à ţŕũśţēď ƒũţũŕē ŕēśēţ ōŕ ŕēţŕŷ ďàţē ŵĥēń ĩţ ĩś ŵĩţĥĩń ţĥē ḿàxĩḿũḿ ŵàĩţ.", + "dynamicPolicySkip": "Śķĩƥ ćàńďĩďàţē", + "dynamicPolicyStop": "Śţōƥ ƒōŕ ḿàńũàĺ ŕēćōvēŕŷ", + "dynamicPolicyRetryHelp": "Ŕēţŕĩēś ţĥē śàḿē ćàńďĩďàţē ŵĩţĥ ēxƥōńēńţĩàĺ ƀàćķōƒƒ ƀēƒōŕē ţĥē śēĺēćţēď ōũţćōḿē.", + "dynamicPolicyWaitHelp": "Ŵàĩţś ƒōŕ à ţŕũśţēď ƒũţũŕē ƥŕōvĩďēŕ ŕēśēţ ōŕ ŕēţŕŷ ďàţē. Ţĥē ŵàĩţ ĩś ƀōũńďēď àńď ũśēď ōńćē ƥēŕ ćàńďĩďàţē àńď ćĺàśś.", + "dynamicPolicyOutcomeHelp": "Ćōńţŕōĺś ŵĥàţ Ķàńďēv ďōēś àƒţēŕ ŕēśēţ ŵàĩţĩńĝ àńď àĺĺ śàḿē-ćàńďĩďàţē ŕēţŕĩēś àŕē ēxĥàũśţēď.", + "dynamicPolicyOptionInfo": "Ēxƥĺàĩń {{option}} ƥōĺĩćŷ", + "addDynamicCandidate": "Àďď ćàńďĩďàţē", + "noDynamicCandidates": "Àďď àţ ĺēàśţ ōńē ćōńćŕēţē ƥŕōƒĩĺē.", + "moveDynamicCandidateUp": "Ḿōvē ćàńďĩďàţē ũƥ", + "moveDynamicCandidateDown": "Ḿōvē ćàńďĩďàţē ďōŵń", + "removeDynamicCandidate": "Ŕēḿōvē ćàńďĩďàţē", + "dynamicCandidatePickerDescription": "Ćĥōōśē à ćōńćŕēţē ƥŕōƒĩĺē ţĥàţ ĩś ēĺĩĝĩƀĺē ƒōŕ ďŷńàḿĩć ŕōũţĩńĝ.", + "searchDynamicCandidates": "Śēàŕćĥ àĝēńţ ƥŕōƒĩĺēś...", + "noDynamicCandidatesFound": "Ńō ēĺĩĝĩƀĺē àĝēńţ ƥŕōƒĩĺēś ƒōũńď.", + "dynamicRouteAction": "Ōń ƥŕōvĩďēŕ ēŕŕōŕ", + "dynamicRouteActionInfo": "Ēxƥĺàĩń {{action}} ŕōũţĩńĝ ƀēĥàvĩōŕ", + "dynamicRouteRetryDescription": "Ŕēţŕŷ ţĥē śàḿē ćàńďĩďàţē àƒţēŕ à ƥŕōvĩďēŕ ēŕŕōŕ. Ũśē ţĥĩś ŵĥēń à ţēḿƥōŕàŕŷ ƒàĩĺũŕē ḿàŷ ćĺēàŕ.", + "dynamicRouteTryNextDescription": "Śķĩƥ ţĥĩś ćàńďĩďàţē ƒōŕ ţĥē ćũŕŕēńţ ţũŕń àńď ćōńţĩńũē ŵĩţĥ ţĥē ńēxţ ēĺĩĝĩƀĺē ƥŕōƒĩĺē.", + "dynamicRouteStopDescription": "Śţōƥ ŕōũţĩńĝ àƒţēŕ ţĥĩś ćàńďĩďàţē ƒàĩĺś. Ţĥē ţàśķ ŵàĩţś ƒōŕ à ḿàńũàĺ ŕēćōvēŕŷ àćţĩōń.", + "dynamicProfileSaved": "Ďŷńàḿĩć ƥŕōƒĩĺē śàvēď.", + "savingProfile": "Śàvĩńĝ...", "saveProfile": "Śàvē ƥŕōƒĩĺē", "createProfile": "Ćŕēàţē ƥŕōƒĩĺē", - "savingProfile": "Śàvĩńĝ...", "creatingProfile": "Ćŕēàţĩńĝ..." } diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index e8524227e5..a33eacf53b 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -1692,6 +1692,27 @@ "sessionActionSetPrimary": "Śēţ ƥŕĩḿàŕŷ", "sessionActionStop": "Śţōƥƥĩńĝ śēśśĩōń", "sessionActionSuccessful": "{{action}} śũććēśśƒũĺ", + "dynamicRouteWaiting": "Ńō ēĺĩĝĩƀĺē àĝēńţ ĩś àvàĩĺàƀĺē.", + "dynamicRouteActionRequired": "Ţĥĩś ŕōũţē ńēēďś à ŕēćōvēŕŷ àćţĩōń.", + "dynamicRouteRetry": "Ŕēţŕŷ ćũŕŕēńţ àĝēńţ", + "dynamicRouteRetryNow": "Ŕēţŕŷ ńōŵ", + "dynamicRouteTryNext": "Ţŕŷ ńēxţ àĝēńţ", + "dynamicRouteSkipNow": "Śķĩƥ ńōŵ", + "dynamicRouteRetrying": "Ŕēţŕŷĩńĝ àĝēńţ...", + "dynamicRouteTryNexting": "Ţŕŷĩńĝ ńēxţ àĝēńţ...", + "dynamicRouteSkipping": "Śķĩƥƥĩńĝ àĝēńţ...", + "dynamicRouteCancelWait": "Ćàńćēĺ ŵàĩţ", + "dynamicRouteStop": "Śţōƥ", + "dynamicRouteReasonCandidateOrder": "Śēĺēćţēď ƀŷ ƥŕōƒĩĺē ōŕďēŕ", + "dynamicRouteReasonNoEligibleCandidate": "Ńō ēĺĩĝĩƀĺē ćàńďĩďàţē", + "dynamicRouteReasonProviderUnavailable": "Ƥŕōvĩďēŕ ũńàvàĩĺàƀĺē", + "dynamicRouteReasonQuotaLimited": "Ƥŕōvĩďēŕ ũśàĝē ĺĩḿĩţ", + "dynamicRouteErrorClass": "Ēŕŕōŕ ćĺàśś: {{class}}", + "dynamicRouteErrorCode": "Ēŕŕōŕ ćōďē: {{code}}", + "dynamicRouteRetryOrdinal_one": "Ŕēţŕŷ {{count}}", + "dynamicRouteRetryOrdinal_other": "Ŕēţŕŷ {{count}}", + "dynamicRouteDeadline": "Ńēxţ ŕēćōvēŕŷ àţţēḿƥţ: {{deadline}}", + "dynamicRoutePendingOutcome": "Àƒţēŕ ŕēćōvēŕŷ: {{outcome}}", "sessionCompleted": "Ţĥĩś śēśśĩōń ĩś ćōḿƥĺēţē.", "composerPlaceholderRecovery": "Ćĥōōśē à ŕēćōvēŕŷ ōƥţĩōń àƀōvē ţō ćōńţĩńũē...", "composerPlaceholderClarification": "Qũēũē ĩńśţŕũćţĩōńś ŵĥĩĺē ţĥē qũēśţĩōń ĩś ƥēńďĩńĝ...", diff --git a/apps/web/src/locales/pt-pt/agents.json b/apps/web/src/locales/pt-pt/agents.json index 681c3236b5..84a58f2e4b 100644 --- a/apps/web/src/locales/pt-pt/agents.json +++ b/apps/web/src/locales/pt-pt/agents.json @@ -235,5 +235,54 @@ "profileCount_one": "{{count}} perfil", "profileCount_other": "{{count}} perfis", "saveProfile": "Guardar perfil", - "savingProfile": "A guardar..." + "savingProfile": "A guardar...", + "dynamicAgents": "Agentes dinâmicos", + "dynamicAgentsDescription": "Encaminhe um perfil lógico através de uma lista ordenada de perfis de agentes concretos.", + "dynamicProfileDisabled": "O encaminhamento dinâmico de agentes está desativado.", + "dynamicProfileDescription": "Escolha uma lista ordenada de perfis concretos para execução alternativa.", + "dynamicProfileSettings": "Definições do perfil dinâmico", + "dynamicCandidates": "Candidatos", + "dynamicCandidatesDescription": "Os candidatos são executados por ordem. O candidato seguinte só é usado quando a ação de erro configurada o permite.", + "dynamicRoutingPolicyTitle": "Quando o Kandev muda de candidato", + "dynamicRoutingPolicySwitchable": "A mudança pode ocorrer após falhas classificadas do fornecedor: problemas de autenticação ou subscrição, credenciais ou configuração em falta, quotas ou limites de taxa, modelos indisponíveis, capacidade, rede e indisponibilidade ou sobrecarga do fornecedor.", + "dynamicRoutingPolicyExcluded": "O Kandev não muda para erros de tarefa, repositório, permissões, ferramentas ou runtime, perda de transporte ou resultados ambíguos a meio da execução. Estes casos param para recuperação manual para evitar repetir trabalho.", + "dynamicRoutingPolicyRecovery": "Após uma mudança, o fornecedor com falha fica pausado durante 1 minuto ou até à hora de reposição, quando fornecida. O Kandev executa então uma verificação de saúde exclusiva antes de o voltar a usar.", + "dynamicPolicyWaitExplanation": "A espera pela reposição usa uma data futura de reposição fiável uma vez por candidato e classe de erro, apenas quando está dentro do máximo definido. Depois da espera, o Kandev faz uma nova tentativa antes de aplicar a política de repetição.", + "dynamicTransientErrors": "Erros transitórios", + "dynamicTransientErrorsDescription": "Condições temporárias, como capacidade, limites de taxa, falhas de rede, timeouts, indisponibilidade do fornecedor e interrupções de transporte.", + "dynamicHardErrors": "Erros permanentes", + "dynamicHardErrorsDescription": "Condições de conta ou configuração, como limites de subscrição, créditos esgotados, autenticação, credenciais em falta, modelos indisponíveis ou configuração do fornecedor.", + "dynamicPolicyRetry": "Repetir este candidato", + "dynamicPolicyWaitForReset": "Aguardar pela reposição do fornecedor", + "dynamicPolicyAfterExhausted": "Depois de esgotar a recuperação", + "dynamicPolicyMaxRetries": "Número máximo de tentativas", + "dynamicPolicyInitialInterval": "Intervalo inicial (segundos)", + "dynamicPolicyMaxWait": "Espera máxima pela reposição (segundos)", + "dynamicPolicyValidation": "Corrija os valores de política destacados antes de guardar.", + "dynamicPolicyMaxRetriesValidation": "Introduza entre 1 e 10 tentativas.", + "dynamicPolicyInitialIntervalValidation": "Introduza um intervalo inicial entre 1 segundo e 1 hora.", + "dynamicPolicyMaxWaitValidation": "Introduza uma espera pela reposição entre 1 segundo e 7 dias.", + "dynamicPolicySchedule": "Repetir até {{retries}} vezes. A primeira tentativa aguarda {{first}} segundos e o intervalo duplica depois.", + "dynamicPolicyNoRetry": "Sem repetições no mesmo candidato. O Kandev aplica imediatamente o resultado selecionado.", + "dynamicPolicyWaitDescription": "Usar uma data futura de reposição ou nova tentativa fiável quando estiver dentro da espera máxima.", + "dynamicPolicySkip": "Ignorar candidato", + "dynamicPolicyStop": "Parar para recuperação manual", + "dynamicPolicyRetryHelp": "Repete o mesmo candidato com espera exponencial antes do resultado selecionado.", + "dynamicPolicyWaitHelp": "Aguarda uma data futura fiável de reposição ou nova tentativa do fornecedor. A espera é limitada e usada uma vez por candidato e classe.", + "dynamicPolicyOutcomeHelp": "Controla o que o Kandev faz depois de esgotar a espera pela reposição e todas as tentativas do mesmo candidato.", + "dynamicPolicyOptionInfo": "Explicar a política de {{option}}", + "addDynamicCandidate": "Adicionar candidato", + "noDynamicCandidates": "Adicione pelo menos um perfil concreto.", + "moveDynamicCandidateUp": "Mover candidato para cima", + "moveDynamicCandidateDown": "Mover candidato para baixo", + "removeDynamicCandidate": "Remover candidato", + "dynamicCandidatePickerDescription": "Escolha um perfil concreto elegível para encaminhamento dinâmico.", + "searchDynamicCandidates": "Pesquisar perfis de agentes...", + "noDynamicCandidatesFound": "Não foram encontrados perfis de agentes elegíveis.", + "dynamicRouteAction": "Em caso de erro do fornecedor", + "dynamicRouteActionInfo": "Explicar o comportamento de encaminhamento: {{action}}", + "dynamicRouteRetryDescription": "Tenta novamente o mesmo candidato depois de um erro do fornecedor. Use esta opção quando a falha puder ser temporária.", + "dynamicRouteTryNextDescription": "Ignora este candidato nesta execução e continua com o próximo perfil elegível.", + "dynamicRouteStopDescription": "Para o encaminhamento quando este candidato falha. A tarefa aguarda uma ação manual de recuperação.", + "dynamicProfileSaved": "Perfil dinâmico guardado." } diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index 2156775c8a..20525f3f82 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -1783,6 +1783,27 @@ "modelSelectionRemediationUnknown": "Verifique a configuração do executor.", "modelSelectionRequested": "Modelo pedido", "modelSelectionWarning": "O executor não conseguiu usar a seleção de modelo guardada.", + "dynamicRouteWaiting": "Não existe um agente elegível disponível.", + "dynamicRouteActionRequired": "Esta rota precisa de uma ação de recuperação.", + "dynamicRouteRetry": "Repetir agente atual", + "dynamicRouteRetryNow": "Repetir agora", + "dynamicRouteTryNext": "Tentar próximo agente", + "dynamicRouteSkipNow": "Ignorar agora", + "dynamicRouteRetrying": "A repetir agente...", + "dynamicRouteTryNexting": "A tentar próximo agente...", + "dynamicRouteSkipping": "A ignorar agente...", + "dynamicRouteCancelWait": "Cancelar espera", + "dynamicRouteStop": "Parar", + "dynamicRouteReasonCandidateOrder": "Selecionado pela ordem do perfil", + "dynamicRouteReasonNoEligibleCandidate": "Nenhum candidato elegível", + "dynamicRouteReasonProviderUnavailable": "Fornecedor indisponível", + "dynamicRouteReasonQuotaLimited": "Limite de utilização do fornecedor", + "dynamicRouteErrorClass": "Classe de erro: {{class}}", + "dynamicRouteErrorCode": "Código de erro: {{code}}", + "dynamicRouteRetryOrdinal_one": "Repetição {{count}}", + "dynamicRouteRetryOrdinal_other": "Repetições {{count}}", + "dynamicRouteDeadline": "Próxima tentativa de recuperação: {{deadline}}", + "dynamicRoutePendingOutcome": "Depois da recuperação: {{outcome}}", "collapsePrompt": "Recolher prompt", "durationUnitHours": "h", "durationUnitMinutes": "m", diff --git a/apps/web/src/locales/zh-cn/agents.json b/apps/web/src/locales/zh-cn/agents.json index 728473453e..52f5becf9a 100644 --- a/apps/web/src/locales/zh-cn/agents.json +++ b/apps/web/src/locales/zh-cn/agents.json @@ -235,5 +235,54 @@ "createProfile": "创建配置", "creatingProfile": "正在创建…", "saveProfile": "保存配置", - "savingProfile": "正在保存…" + "savingProfile": "正在保存…", + "dynamicAgents": "动态代理", + "dynamicAgentsDescription": "通过有序的具体代理配置列表路由一个逻辑配置。", + "dynamicProfileDisabled": "动态代理路由已禁用。", + "dynamicProfileDescription": "选择按顺序执行的具体配置,以便在失败时切换。", + "dynamicProfileSettings": "动态配置设置", + "dynamicCandidates": "候选配置", + "dynamicCandidatesDescription": "候选配置按顺序执行。只有在配置的错误操作允许时,才会使用下一个候选配置。", + "dynamicRoutingPolicyTitle": "Kandev 何时切换候选配置", + "dynamicRoutingPolicySwitchable": "切换可以由已分类的提供商故障触发,包括身份验证或订阅问题、缺少凭据或配置、配额或速率限制、模型不可用、容量不足、网络问题,以及提供商不可用或过载。", + "dynamicRoutingPolicyExcluded": "对于任务、仓库、权限、工具或运行时错误、传输中断,或执行中途结果不明确的情况,Kandev 不会切换。这些情况会停止并等待手动恢复,以避免重复工作。", + "dynamicRoutingPolicyRecovery": "切换后,发生故障的提供商会暂停 1 分钟,或者暂停到已提供的重置时间。之后,Kandev 会先执行一次独占健康探测,再次使用该提供商。", + "dynamicPolicyWaitExplanation": "重置等待会在每个候选配置和错误类别中使用一次可信的未来重置时间,并且只在时间不超过最大等待时间时使用。等待结束后,Kandev 会先进行一次新尝试,再应用重试策略。", + "dynamicTransientErrors": "暂时性错误", + "dynamicTransientErrorsDescription": "临时状况,例如容量不足、速率限制、网络故障、超时、提供商中断和传输中断。", + "dynamicHardErrors": "硬错误", + "dynamicHardErrorsDescription": "账户或配置状况,例如订阅限制、额度耗尽、身份验证、缺少凭据、模型不可用或提供商配置问题。", + "dynamicPolicyRetry": "重试此候选配置", + "dynamicPolicyWaitForReset": "等待提供商重置", + "dynamicPolicyAfterExhausted": "恢复操作耗尽后", + "dynamicPolicyMaxRetries": "最大重试次数", + "dynamicPolicyInitialInterval": "初始间隔(秒)", + "dynamicPolicyMaxWait": "最大重置等待时间(秒)", + "dynamicPolicyValidation": "保存前请修正突出显示的策略值。", + "dynamicPolicyMaxRetriesValidation": "请输入 1 到 10 次重试。", + "dynamicPolicyInitialIntervalValidation": "请输入 1 秒到 1 小时之间的初始间隔。", + "dynamicPolicyMaxWaitValidation": "请输入 1 秒到 7 天之间的重置等待时间。", + "dynamicPolicySchedule": "最多重试 {{retries}} 次。第一次重试等待 {{first}} 秒,之后间隔加倍。", + "dynamicPolicyNoRetry": "不重试同一候选配置。Kandev 会立即应用所选结果。", + "dynamicPolicyWaitDescription": "当可信的未来重置或重试时间不超过最大等待时间时使用它。", + "dynamicPolicySkip": "跳过候选配置", + "dynamicPolicyStop": "停止并手动恢复", + "dynamicPolicyRetryHelp": "在应用所选结果前,以指数退避方式重试同一候选配置。", + "dynamicPolicyWaitHelp": "等待可信的未来提供商重置或重试时间。等待时间有限制,并且每个候选配置和类别只使用一次。", + "dynamicPolicyOutcomeHelp": "控制重置等待和同一候选配置的所有重试都耗尽后 Kandev 的操作。", + "dynamicPolicyOptionInfo": "解释 {{option}} 策略", + "addDynamicCandidate": "添加候选配置", + "noDynamicCandidates": "至少添加一个具体配置。", + "moveDynamicCandidateUp": "上移候选配置", + "moveDynamicCandidateDown": "下移候选配置", + "removeDynamicCandidate": "移除候选配置", + "dynamicCandidatePickerDescription": "选择一个符合动态路由条件的具体配置。", + "searchDynamicCandidates": "搜索代理配置...", + "noDynamicCandidatesFound": "未找到符合条件的代理配置。", + "dynamicRouteAction": "提供商出错时", + "dynamicRouteActionInfo": "说明{{action}}路由行为", + "dynamicRouteRetryDescription": "提供商出错后重试同一候选配置。适用于可能暂时恢复的故障。", + "dynamicRouteTryNextDescription": "本次执行跳过此候选配置,继续使用下一个符合条件的配置。", + "dynamicRouteStopDescription": "此候选配置失败后停止路由。任务会等待手动恢复操作。", + "dynamicProfileSaved": "动态配置已保存。" } diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index 16b3179c0c..ca8bff982e 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -1783,6 +1783,27 @@ "modelSelectionRemediationUnknown": "请检查执行器配置。", "modelSelectionRequested": "请求的模型", "modelSelectionWarning": "执行器无法使用已保存的模型选择。", + "dynamicRouteWaiting": "没有可用的合格代理。", + "dynamicRouteActionRequired": "此路由需要恢复操作。", + "dynamicRouteRetry": "重试当前代理", + "dynamicRouteRetryNow": "立即重试", + "dynamicRouteTryNext": "尝试下一个代理", + "dynamicRouteSkipNow": "立即跳过", + "dynamicRouteRetrying": "正在重试代理...", + "dynamicRouteTryNexting": "正在尝试下一个代理...", + "dynamicRouteSkipping": "正在跳过代理...", + "dynamicRouteCancelWait": "取消等待", + "dynamicRouteStop": "停止", + "dynamicRouteReasonCandidateOrder": "按配置文件顺序选择", + "dynamicRouteReasonNoEligibleCandidate": "没有合格的候选项", + "dynamicRouteReasonProviderUnavailable": "提供商不可用", + "dynamicRouteReasonQuotaLimited": "提供商使用量限制", + "dynamicRouteErrorClass": "错误类别:{{class}}", + "dynamicRouteErrorCode": "错误代码:{{code}}", + "dynamicRouteRetryOrdinal_one": "重试 {{count}} 次", + "dynamicRouteRetryOrdinal_other": "重试 {{count}} 次", + "dynamicRouteDeadline": "下一次恢复尝试:{{deadline}}", + "dynamicRoutePendingOutcome": "恢复后:{{outcome}}", "collapsePrompt": "收起提示", "durationUnitHours": "时", "durationUnitMinutes": "分", diff --git a/apps/web/src/locales/zh-hk/agents.json b/apps/web/src/locales/zh-hk/agents.json index 3a52e5366e..3234e2b4c2 100644 --- a/apps/web/src/locales/zh-hk/agents.json +++ b/apps/web/src/locales/zh-hk/agents.json @@ -235,5 +235,54 @@ "createProfile": "建立設定", "creatingProfile": "正在建立…", "saveProfile": "儲存設定", - "savingProfile": "正在儲存…" + "savingProfile": "正在儲存…", + "dynamicAgents": "動態代理", + "dynamicAgentsDescription": "透過有序的具體代理設定檔列表路由一個邏輯設定檔。", + "dynamicProfileDisabled": "動態代理程式路由已停用。", + "dynamicProfileDescription": "選擇按順序執行的具體設定檔,以便在失敗時切換。", + "dynamicProfileSettings": "動態設定檔設定", + "dynamicCandidates": "候選設定檔", + "dynamicCandidatesDescription": "候選設定檔按順序執行。只有在已設定的錯誤動作允許時,才會使用下一個候選設定檔。", + "dynamicRoutingPolicyTitle": "Kandev 何時切換候選設定檔", + "dynamicRoutingPolicySwitchable": "切換可以由已分類的供應商故障觸發,包括驗證或訂閱問題、缺少憑證或設定、配額或速率限制、模型不可用、容量不足、網絡問題,以及供應商不可用或過載。", + "dynamicRoutingPolicyExcluded": "對於工作、儲存庫、權限、工具或執行階段錯誤、傳輸中斷,或執行中途結果不明確的情況,Kandev 不會切換。這些情況會停止並等待手動恢復,以避免重複工作。", + "dynamicRoutingPolicyRecovery": "切換後,發生故障的供應商會暫停 1 分鐘,或者暫停至已提供的重設時間。之後,Kandev 會先執行一次獨佔健康探測,再次使用該供應商。", + "dynamicPolicyWaitExplanation": "重設等待會在每個候選設定檔和錯誤類別中使用一次可信的未來重設時間,並且只在時間不超過最大等待時間時使用。等待結束後,Kandev 會先進行一次新嘗試,再套用重試策略。", + "dynamicTransientErrors": "暫時性錯誤", + "dynamicTransientErrorsDescription": "臨時狀況,例如容量不足、速率限制、網絡故障、逾時、供應商中斷和傳輸中斷。", + "dynamicHardErrors": "硬錯誤", + "dynamicHardErrorsDescription": "帳戶或設定狀況,例如訂閱限制、額度耗盡、驗證、缺少憑證、模型不可用或供應商設定問題。", + "dynamicPolicyRetry": "重試此候選設定檔", + "dynamicPolicyWaitForReset": "等待供應商重設", + "dynamicPolicyAfterExhausted": "恢復操作耗盡後", + "dynamicPolicyMaxRetries": "最大重試次數", + "dynamicPolicyInitialInterval": "初始間隔(秒)", + "dynamicPolicyMaxWait": "最大重設等待時間(秒)", + "dynamicPolicyValidation": "儲存前請修正標示的策略值。", + "dynamicPolicyMaxRetriesValidation": "請輸入 1 至 10 次重試。", + "dynamicPolicyInitialIntervalValidation": "請輸入 1 秒至 1 小時之間的初始間隔。", + "dynamicPolicyMaxWaitValidation": "請輸入 1 秒至 7 日之間的重設等待時間。", + "dynamicPolicySchedule": "最多重試 {{retries}} 次。第一次重試等待 {{first}} 秒,之後間隔加倍。", + "dynamicPolicyNoRetry": "不重試同一候選設定檔。Kandev 會立即套用所選結果。", + "dynamicPolicyWaitDescription": "當可信的未來重設或重試時間不超過最大等待時間時使用它。", + "dynamicPolicySkip": "略過候選設定檔", + "dynamicPolicyStop": "停止並手動恢復", + "dynamicPolicyRetryHelp": "在套用所選結果前,以指數退避方式重試同一候選設定檔。", + "dynamicPolicyWaitHelp": "等待可信的未來供應商重設或重試時間。等待時間有限制,並且每個候選設定檔和類別只使用一次。", + "dynamicPolicyOutcomeHelp": "控制重設等待和同一候選設定檔的所有重試都耗盡後 Kandev 的操作。", + "dynamicPolicyOptionInfo": "解釋 {{option}} 策略", + "addDynamicCandidate": "新增候選設定檔", + "noDynamicCandidates": "至少新增一個具體設定檔。", + "moveDynamicCandidateUp": "上移候選設定檔", + "moveDynamicCandidateDown": "下移候選設定檔", + "removeDynamicCandidate": "移除候選設定檔", + "dynamicCandidatePickerDescription": "選擇符合動態路由條件的具體設定檔。", + "searchDynamicCandidates": "搜尋代理設定檔...", + "noDynamicCandidatesFound": "找不到符合條件的代理設定檔。", + "dynamicRouteAction": "供應商發生錯誤時", + "dynamicRouteActionInfo": "說明{{action}}路由行為", + "dynamicRouteRetryDescription": "供應商發生錯誤後重試相同候選設定檔。適用於可能暫時恢復的故障。", + "dynamicRouteTryNextDescription": "本次執行略過此候選設定檔,繼續使用下一個符合條件的設定檔。", + "dynamicRouteStopDescription": "此候選設定檔失敗後停止路由。工作會等待手動恢復操作。", + "dynamicProfileSaved": "動態設定檔已儲存。" } diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index ae15fde729..3bb06519d6 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -1783,6 +1783,27 @@ "modelSelectionRemediationUnknown": "請檢查執行器設定檔。", "modelSelectionRequested": "請求的模型", "modelSelectionWarning": "執行器無法使用已儲存的模型選擇。", + "dynamicRouteWaiting": "沒有可用的合資格代理程式。", + "dynamicRouteActionRequired": "此路由需要復原操作。", + "dynamicRouteRetry": "重試目前代理程式", + "dynamicRouteRetryNow": "立即重試", + "dynamicRouteTryNext": "嘗試下一個代理程式", + "dynamicRouteSkipNow": "立即略過", + "dynamicRouteRetrying": "正在重試代理程式...", + "dynamicRouteTryNexting": "正在嘗試下一個代理程式...", + "dynamicRouteSkipping": "正在略過代理程式...", + "dynamicRouteCancelWait": "取消等待", + "dynamicRouteStop": "停止", + "dynamicRouteReasonCandidateOrder": "按設定檔順序選取", + "dynamicRouteReasonNoEligibleCandidate": "沒有合資格的候選項目", + "dynamicRouteReasonProviderUnavailable": "供應商不可用", + "dynamicRouteReasonQuotaLimited": "供應商使用量限制", + "dynamicRouteErrorClass": "錯誤類別:{{class}}", + "dynamicRouteErrorCode": "錯誤代碼:{{code}}", + "dynamicRouteRetryOrdinal_one": "重試 {{count}} 次", + "dynamicRouteRetryOrdinal_other": "重試 {{count}} 次", + "dynamicRouteDeadline": "下一次恢復嘗試:{{deadline}}", + "dynamicRoutePendingOutcome": "恢復後:{{outcome}}", "collapsePrompt": "收起提示", "durationUnitHours": "時", "durationUnitMinutes": "分", diff --git a/apps/web/src/locales/zh-tw/agents.json b/apps/web/src/locales/zh-tw/agents.json index ea07c7dcb5..3a4a65ee49 100644 --- a/apps/web/src/locales/zh-tw/agents.json +++ b/apps/web/src/locales/zh-tw/agents.json @@ -235,5 +235,54 @@ "createProfile": "建立設定", "creatingProfile": "正在建立…", "saveProfile": "儲存設定", - "savingProfile": "正在儲存…" + "savingProfile": "正在儲存…", + "dynamicAgents": "動態代理", + "dynamicAgentsDescription": "透過有序的具體代理設定檔清單路由一個邏輯設定檔。", + "dynamicProfileDisabled": "動態代理程式路由已停用。", + "dynamicProfileDescription": "選擇按順序執行的具體設定檔,以便在失敗時切換。", + "dynamicProfileSettings": "動態設定檔設定", + "dynamicCandidates": "候選設定檔", + "dynamicCandidatesDescription": "候選設定檔按順序執行。只有在已設定的錯誤動作允許時,才會使用下一個候選設定檔。", + "dynamicRoutingPolicyTitle": "Kandev 何時切換候選設定檔", + "dynamicRoutingPolicySwitchable": "切換可以由已分類的供應商故障觸發,包括驗證或訂閱問題、缺少憑證或設定、配額或速率限制、模型無法使用、容量不足、網路問題,以及供應商無法使用或過載。", + "dynamicRoutingPolicyExcluded": "對於工作、儲存庫、權限、工具或執行階段錯誤、傳輸中斷,或執行中途結果不明確的情況,Kandev 不會切換。這些情況會停止並等待手動復原,以避免重複工作。", + "dynamicRoutingPolicyRecovery": "切換後,發生故障的供應商會暫停 1 分鐘,或者暫停至已提供的重設時間。之後,Kandev 會先執行一次獨佔健康探測,再次使用該供應商。", + "dynamicPolicyWaitExplanation": "重設等待會在每個候選設定檔和錯誤類別中使用一次可信的未來重設時間,並且只在時間不超過最大等待時間時使用。等待結束後,Kandev 會先進行一次新嘗試,再套用重試策略。", + "dynamicTransientErrors": "暫時性錯誤", + "dynamicTransientErrorsDescription": "臨時狀況,例如容量不足、速率限制、網路故障、逾時、供應商中斷和傳輸中斷。", + "dynamicHardErrors": "硬錯誤", + "dynamicHardErrorsDescription": "帳戶或設定狀況,例如訂閱限制、額度耗盡、驗證、缺少憑證、模型無法使用或供應商設定問題。", + "dynamicPolicyRetry": "重試此候選設定檔", + "dynamicPolicyWaitForReset": "等待供應商重設", + "dynamicPolicyAfterExhausted": "復原操作耗盡後", + "dynamicPolicyMaxRetries": "最大重試次數", + "dynamicPolicyInitialInterval": "初始間隔(秒)", + "dynamicPolicyMaxWait": "最大重設等待時間(秒)", + "dynamicPolicyValidation": "儲存前請修正標示的策略值。", + "dynamicPolicyMaxRetriesValidation": "請輸入 1 至 10 次重試。", + "dynamicPolicyInitialIntervalValidation": "請輸入 1 秒至 1 小時之間的初始間隔。", + "dynamicPolicyMaxWaitValidation": "請輸入 1 秒至 7 日之間的重設等待時間。", + "dynamicPolicySchedule": "最多重試 {{retries}} 次。第一次重試等待 {{first}} 秒,之後間隔加倍。", + "dynamicPolicyNoRetry": "不重試同一候選設定檔。Kandev 會立即套用所選結果。", + "dynamicPolicyWaitDescription": "當可信的未來重設或重試時間不超過最大等待時間時使用它。", + "dynamicPolicySkip": "略過候選設定檔", + "dynamicPolicyStop": "停止並手動復原", + "dynamicPolicyRetryHelp": "在套用所選結果前,以指數退避方式重試同一候選設定檔。", + "dynamicPolicyWaitHelp": "等待可信的未來供應商重設或重試時間。等待時間有限制,並且每個候選設定檔和類別只使用一次。", + "dynamicPolicyOutcomeHelp": "控制重設等待和同一候選設定檔的所有重試都耗盡後 Kandev 的操作。", + "dynamicPolicyOptionInfo": "解釋 {{option}} 策略", + "addDynamicCandidate": "新增候選設定檔", + "noDynamicCandidates": "至少新增一個具體設定檔。", + "moveDynamicCandidateUp": "上移候選設定檔", + "moveDynamicCandidateDown": "下移候選設定檔", + "removeDynamicCandidate": "移除候選設定檔", + "dynamicCandidatePickerDescription": "選擇符合動態路由條件的具體設定檔。", + "searchDynamicCandidates": "搜尋代理設定檔...", + "noDynamicCandidatesFound": "找不到符合條件的代理設定檔。", + "dynamicRouteAction": "供應商發生錯誤時", + "dynamicRouteActionInfo": "說明{{action}}路由行為", + "dynamicRouteRetryDescription": "供應商發生錯誤後重試相同候選設定檔。適用於可能暫時恢復的故障。", + "dynamicRouteTryNextDescription": "本次執行略過此候選設定檔,繼續使用下一個符合條件的設定檔。", + "dynamicRouteStopDescription": "此候選設定檔失敗後停止路由。工作會等待手動恢復操作。", + "dynamicProfileSaved": "動態設定檔已儲存。" } diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index 5aa8ad9df1..d738e4889a 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -1783,6 +1783,27 @@ "modelSelectionRemediationUnknown": "請檢查執行器設定檔。", "modelSelectionRequested": "請求的模型", "modelSelectionWarning": "執行器無法使用已儲存的模型選擇。", + "dynamicRouteWaiting": "沒有可用的合格代理程式。", + "dynamicRouteActionRequired": "此路由需要復原操作。", + "dynamicRouteRetry": "重試目前代理程式", + "dynamicRouteRetryNow": "立即重試", + "dynamicRouteTryNext": "嘗試下一個代理程式", + "dynamicRouteSkipNow": "立即略過", + "dynamicRouteRetrying": "正在重試代理程式...", + "dynamicRouteTryNexting": "正在嘗試下一個代理程式...", + "dynamicRouteSkipping": "正在略過代理程式...", + "dynamicRouteCancelWait": "取消等待", + "dynamicRouteStop": "停止", + "dynamicRouteReasonCandidateOrder": "依設定檔順序選取", + "dynamicRouteReasonNoEligibleCandidate": "沒有合格的候選項目", + "dynamicRouteReasonProviderUnavailable": "供應商無法使用", + "dynamicRouteReasonQuotaLimited": "供應商使用量限制", + "dynamicRouteErrorClass": "錯誤類別:{{class}}", + "dynamicRouteErrorCode": "錯誤代碼:{{code}}", + "dynamicRouteRetryOrdinal_one": "重試 {{count}} 次", + "dynamicRouteRetryOrdinal_other": "重試 {{count}} 次", + "dynamicRouteDeadline": "下一次復原嘗試:{{deadline}}", + "dynamicRoutePendingOutcome": "復原後:{{outcome}}", "collapsePrompt": "收起提示", "durationUnitHours": "時", "durationUnitMinutes": "分", diff --git a/docs/decisions/2026-07-15-office-agent-execution-profile-routing.md b/docs/decisions/2026-07-15-office-agent-execution-profile-routing.md index 21677b7694..d3c3611693 100644 --- a/docs/decisions/2026-07-15-office-agent-execution-profile-routing.md +++ b/docs/decisions/2026-07-15-office-agent-execution-profile-routing.md @@ -1,9 +1,15 @@ # ADR-2026-07-15-office-agent-execution-profile-routing: Separate Office identity from routed execution profiles -**Status:** proposed +**Status:** superseded by ADR-2026-08-13-dynamic-agent-profile-routing **Date:** 2026-07-15 **Area:** backend, frontend +This decision is superseded by +[Unify Provider Routing Behind Dynamic Agent Profiles](2026-08-13-dynamic-agent-profile-routing.md). +The newer decision retains the separation between Office identity and concrete +execution configuration, but moves routing ownership from Office workspace +settings to reusable dynamic agent profiles. + ## Context ADR 0005 unified shallow CLI profiles and rich Office agents into one `agent_profiles` table. That removed duplicate identity rows and made Office agents usable by the shared task and workflow model. It also left one row carrying two responsibilities: diff --git a/docs/decisions/2026-07-18-turn-configuration-snapshots.md b/docs/decisions/2026-07-18-turn-configuration-snapshots.md index b84c74be4d..1df4dff609 100644 --- a/docs/decisions/2026-07-18-turn-configuration-snapshots.md +++ b/docs/decisions/2026-07-18-turn-configuration-snapshots.md @@ -1,9 +1,17 @@ # ADR-2026-07-18-turn-configuration-snapshots: Attribute Runtime Configuration to Turns -**Status:** accepted +**Status:** accepted (amended 2026-08-13) **Date:** 2026-07-18 **Area:** backend, frontend +## Amendment (2026-08-13) + +[ADR-2026-08-13-dynamic-agent-profile-routing](2026-08-13-dynamic-agent-profile-routing.md) +extends the immutable turn snapshot for routed sessions. At turn creation, the +snapshot also captures the concrete execution profile, safe provider/model +labels, and route generation. This keeps partial and completed output attributed +to the route that produced it after later provider switches. + ## Context Task-session runtime configuration is mutable across a conversation, while agent messages are historical output. Rendering every message with the session's latest model-adjacent options can relabel old output after a user changes model, reasoning, collaboration, or other provider settings. Persisting the same full snapshot on every streamed message would duplicate data and still leave message bundles within one prompt cycle to coordinate. diff --git a/docs/decisions/2026-08-08-provider-neutral-agent-error-recovery.md b/docs/decisions/2026-08-08-provider-neutral-agent-error-recovery.md index f9793ce66e..f3e282ebad 100644 --- a/docs/decisions/2026-08-08-provider-neutral-agent-error-recovery.md +++ b/docs/decisions/2026-08-08-provider-neutral-agent-error-recovery.md @@ -1,9 +1,27 @@ # ADR-2026-08-08-provider-neutral-agent-error-recovery: Separate Agent Error Evidence From Recovery Policy -**Status:** accepted +**Status:** accepted (amended 2026-08-13 and 2026-08-17) **Date:** 2026-08-08 **Area:** backend, frontend, protocol +## Amendment (2026-08-13) + +[ADR-2026-08-13-dynamic-agent-profile-routing](2026-08-13-dynamic-agent-profile-routing.md) +changes which boundary selects recovery policy. Concrete profiles use the +interactive same-profile policy. Dynamic profiles use configured routing and +durable shared health in either Kanban or Office. References below to “Kanban +interactive” mean a concrete-profile session; references to “Office unattended +routing” mean a dynamic-profile session. The provider-neutral evidence and +classification contracts remain unchanged. + +## Amendment (2026-08-17) + +[ADR-2026-08-17-provider-error-classes-and-policies](2026-08-17-provider-error-classes-and-policies.md) +retains the provider-neutral evidence and semantic-code boundary. It replaces +workspace-specific provider allow-lists with shared `transient` and `hard` +policy classes, versioned per-candidate policies, and a common durable policy +evaluator. Unknown or effect-unsafe failures still fail closed. + ## Context Agent CLIs expose equivalent provider failures in incompatible ways. An ACP diff --git a/docs/decisions/2026-08-08-utility-agent-profile-execution.md b/docs/decisions/2026-08-08-utility-agent-profile-execution.md index de5b031453..bcc8ca856c 100644 --- a/docs/decisions/2026-08-08-utility-agent-profile-execution.md +++ b/docs/decisions/2026-08-08-utility-agent-profile-execution.md @@ -1,6 +1,6 @@ # ADR-2026-08-08-utility-agent-profile-execution: Utility Agents Use Execution Profiles -**Status:** accepted +**Status:** accepted (amended 2026-08-14) **Date:** 2026-08-08 **Area:** backend, frontend, protocol @@ -14,9 +14,20 @@ agent, and a provider may request permission after the caller can no longer resp ## Decision +### Amendment (2026-08-14) + +[ADR-2026-08-13-dynamic-agent-profile-routing](2026-08-13-dynamic-agent-profile-routing.md) +extends eligible utility selections to dynamic profiles. The utility caller +still supplies one logical `agent_profile_id`; a shared resolver either launches +the concrete profile or delegates to the dynamic conductor. Dynamic routing may +select another configured concrete candidate under its own policy. The durable +utility call records both the logical selection and concrete +`execution_profile_id`. The permission and fail-closed rules below apply to +each selected concrete attempt. + Utility-agent configuration stores an `agent_profile_id`, not an independent agent/model pair. A built-in action may inherit the user's default utility profile; a built-in override and every custom -utility agent reference one concrete, global agent profile. At invocation start, the backend resolves +utility agent reference one eligible concrete or dynamic global agent profile. At invocation start, the backend resolves that profile and uses its complete launch-affecting configuration for both host-sessionless and task-session-bound utility execution. diff --git a/docs/decisions/2026-08-08-utility-profile-dependency-safety.md b/docs/decisions/2026-08-08-utility-profile-dependency-safety.md index 92dd62d5ea..f609b076be 100644 --- a/docs/decisions/2026-08-08-utility-profile-dependency-safety.md +++ b/docs/decisions/2026-08-08-utility-profile-dependency-safety.md @@ -1,6 +1,6 @@ # ADR-2026-08-08-utility-profile-dependency-safety: Warn Before Breaking Utility Profile Bindings -**Status:** accepted +**Status:** accepted (amended 2026-08-14) **Date:** 2026-08-08 **Area:** backend, frontend, protocol @@ -13,6 +13,16 @@ representation when an old agent/model pair matches no profile or several profil ## Decision +### Amendment (2026-08-14) + +Utility bindings may name concrete or dynamic profiles. Dynamic candidate +references also participate in the same dependency dialog. A confirmed removal +keeps the stored reference: a missing logical dynamic profile makes the utility +binding unconfigured, while a missing concrete candidate makes only that route +ineligible. The dynamic conductor can select another configured candidate; this +is routing authorized by the selected profile, not an implicit utility-profile +replacement. + Profile dependency checks include utility-agent bindings. - Disabling a referenced profile shows a dependency warning before the setting is saved. The user can @@ -29,7 +39,7 @@ Profile dependency checks include utility-agent bindings. Utility binding persistence has an explicit state separate from the profile ID: - `inherit`: a built-in action uses the user default. -- `explicit`: the row names a concrete profile. +- `explicit`: the row names an eligible concrete or dynamic profile. - `unconfigured`: migration or a prior profile change left no executable profile. The migration keeps legacy agent/model values as read-only inputs. It writes `explicit` only when diff --git a/docs/decisions/2026-08-13-dynamic-agent-profile-routing.md b/docs/decisions/2026-08-13-dynamic-agent-profile-routing.md new file mode 100644 index 0000000000..d93707e0b5 --- /dev/null +++ b/docs/decisions/2026-08-13-dynamic-agent-profile-routing.md @@ -0,0 +1,300 @@ +# ADR-2026-08-13-dynamic-agent-profile-routing: Unify Provider Routing Behind Dynamic Agent Profiles + +**Status:** accepted (amended 2026-08-17) +**Date:** 2026-08-13 +**Area:** backend, frontend, protocol, workflow + +## Amendment (2026-08-17) + +[ADR-2026-08-17-provider-error-classes-and-policies](2026-08-17-provider-error-classes-and-policies.md) +replaces generic dynamic candidate actions with separate transient and hard +policies. Each policy can wait for a trusted near reset, retry with bounded +exponential backoff, then skip or stop. The shared routing ownership and +continuation boundaries in this decision remain unchanged. + +## Context + +Kandev's first provider-routing design belongs to Office workspaces. It maps +model tiers to concrete execution profiles, tracks provider health, and falls +back between providers. Kanban tasks need the same capability, including one +logical chat session while the concrete provider changes. + +Keeping routing in Office creates two policy engines and two settings surfaces. +It also creates different recovery behavior for the same user intent. Routing +then depends on where a task runs rather than on the profile that +the user selected. Office has not been enabled for users, so its ownership +boundary can change before compatibility is required. + +ACP gives Kandev a common transport, but a provider-native session cannot be +loaded by a different provider. A routed agent therefore needs a Kandev-owned +logical session above several provider-native ACP sessions. The initial feature +must work from provider errors and shared health without new plugin contracts. +Subscription and cost routing are a separate future feature. + +## Decision + +### Dynamic profiles own routing + +Add a permanent built-in virtual agent family with the canonical ID `dynamic`. +The family is always registered, cannot be disabled or uninstalled, does not +advertise an ACP command, and is excluded from concrete-agent probing. User +profiles created under that family are dynamic profiles. This preserves the +existing `agent_profiles.agent_id` ownership invariant without making a +removable synthetic agent the parent of durable routing policy. + +A dynamic profile is a virtual agent configuration and is not itself a provider +CLI launch configuration. It owns: + +- a user-defined name and an ordered set of concrete agent-profile candidates, +- actions for classified provider errors, and +- route-health, circuit-breaker, probing, and recovery policy. + +A dynamic candidate must be a concrete, launchable profile. Rich Office +identities and dynamic profiles cannot be candidates. An Office identity's +execution binding can select a concrete or dynamic profile because that binding +is not a candidate edge. + +Names such as Frontier, Balanced, Economy, Review, or Security Review express +the user's intent. Kandev does not persist a class or tier field and does not +interpret those names. + +Dynamic profiles are configured with the normal agent settings. Office no +longer owns a provider-routing settings page or a workspace-specific routing +policy. + +### Profile selection is transparent to callers + +Task creation, workflow steps, Office execution bindings, utility agents, and +other profile consumers continue to pass one selected agent-profile ID. A +shared execution resolver inspects that profile behind the selection boundary: + +- a concrete profile resolves directly to its own launch configuration, and +- a dynamic profile resolves to the conductor, which selects and records a + concrete execution profile before launching downstream ACP. + +Callers do not branch on profile kind and do not supply a concrete +`execution_profile_id` for a dynamic profile. Internally, +`execution_profile_id` always identifies the concrete profile that actually +launched. It equals the selected profile ID for a concrete selection and differs +for a dynamic selection. This distinction keeps the abstraction transparent +without treating the non-launchable virtual profile as a CLI configuration. + +Kanban tasks and workflow steps can select a dynamic profile wherever they can +select an agent profile. An Office agent selects either a concrete or dynamic +profile as its execution agent profile. Office wake-reason policy can request a +different execution agent profile for a run, but it cannot define provider +order, mappings, health policy, or fallback rules outside the selected +dynamic profile. + +Utility agents also accept concrete or dynamic profiles. A one-shot utility +invocation has no visible task-session tab, but it uses the same resolver and +dynamic conductor. Its durable call record retains the selected logical profile +and the concrete execution profile that produced the result. + +### Keep identity, policy, and execution separate + +A routed Office run has three distinct identities: + +1. The Office agent identity owns its role, instructions, skills, permissions, + budget, hierarchy, and Office history. +2. The assigned dynamic profile owns routing policy and is the logical agent + shown for the task session. +3. The resolved concrete profile owns the provider CLI, account environment, + model, mode, ACP options, flags, permissions, and MCP configuration for the + current downstream session. + +A routed Kanban run has the latter two identities. A concrete-profile run has +no separate routing identity. + +The runtime and persistence contracts must carry these identities explicitly. +They must not infer one identity from another or construct a hybrid launch by +overlaying one provider's settings on another profile. + +### The dynamic runtime is a virtual ACP conductor + +The task backend, workflow scheduler, and task UI continue to see one Kandev +task session and one chat tab. A dynamic-profile runtime owns the downstream +ACP lifecycle behind that session. + +When a route changes, the conductor: + +1. settles or stops the failed downstream attempt, +2. persists the route transition and invalidates its provider-native resume + token for the successor, +3. starts a new ACP session with the selected concrete profile, +4. builds a bounded continuation prompt from durable task state, run state, + recent conversation, repository state, and an optional handoff summary, and +5. publishes a route-change event followed by an authoritative replacement of + models, modes, configuration options, and custom commands. + +The provider-native session is not exposed as another Kandev task session and +does not create another tab. Same-profile restart can use that provider's +`session/load` or `session/resume`. Cross-profile continuation always starts a +fresh provider-native session. + +These events are Kandev extensions around ACP, not claims that standard ACP can +import one provider's history into another provider. + +### Routing policy follows profile kind + +Provider-error classification remains shared and provider-neutral. The +selected profile kind chooses the recovery owner: + +- A concrete profile uses conservative interactive same-profile recovery. It + never changes account, model, or provider automatically. +- A dynamic profile uses its configured retry and fallback policy in Kanban or + Office. Selecting it is prior authorization to use its eligible routes. + +The dynamic conductor is the only owner of cross-candidate fallback. A concrete +profile with legacy `AutoFallback` enabled is not a valid dynamic candidate. An +explicit `FallbackModel` remains an intra-profile start-model policy and does +not select another dynamic candidate. + +This replaces the earlier rule that Kanban and Office necessarily have +different routing owners. + +### Profile edits use the next routing decision + +Dynamic profile configuration is versioned. Normal edits affect a running +logical session at its next routing decision. They do not churn an otherwise +healthy active route. Disabling or removing the active candidate schedules a +reroute at the next safe turn boundary. Historical route decisions retain the +profile version that produced them. + +### Logical identity and concrete attribution coexist + +The Kanban tab and assistant author keep the dynamic profile's identity. In +Office, they keep the Office agent identity, such as CEO. Every assistant turn +stores immutable concrete profile, provider, model, and route generation +attribution and displays it as compact metadata. A route change also +creates a durable system row, while the live model, mode, options, and commands +always come from the active concrete route. + +Turn attribution extends the immutable snapshot boundary from +[Attribute Runtime Configuration to Turns](2026-07-18-turn-configuration-snapshots.md), +historical messages never inherit the session's latest route. + +### Health is shared by the resource that failed + +Route health uses a scope-specific resource key. Provider outages use provider +scope. Quota failures use provider and credential-binding scope. Model evidence +adds model scope. Profile configuration errors use concrete-profile scope. + +Each concrete launch adapter produces a versioned +`CredentialBindingDescriptor` after environment resolution. It includes the +canonical family, authentication mechanism, non-secret credential source, +executor namespace, authorization scope, and applicable workspace scope. +`CredentialBindingResolver` canonicalizes the descriptor and computes an +HMAC-SHA-256 fingerprint with a persistent Kandev installation key. It cannot +include raw credentials, credential-file contents, literal environment values, +`CommandPrefix`, CLI flags, models, prompts, or raw account IDs. If an adapter +cannot prove a shared binding, the resolver uses a conservative profile-scoped +identity. + +A classified hard or long-horizon failure atomically opens the shared circuit. +Other dynamic sessions that reference the same resource skip it while the +circuit is open. One half-open attempt owns the probe lease. Successful probes +close the circuit, failed probes extend its deadline. Existing sessions on a +fallback route are not moved back automatically when an earlier candidate +recovers. Recovery affects new selections and explicit safe-boundary reroutes. + +### Telemetry routing is a separate feature + +This decision implements the virtual family, transparent resolver, fixed +candidate order, classified-error fallback, shared circuits and probes, the ACP +conductor, continuation, restart recovery, and Kanban, Office, and utility +integration. It does not add session-cost or subscription-usage plugin +contracts. It does not add telemetry-backed rules or turn interruption. + +Those behaviors belong to the separate +[Dynamic Agent Telemetry Routing](../specs/agents/dynamic-agent-telemetry-routing.md) +feature and implementation package. An implementation of this decision must not +implement that deferred package. + +### Legacy Office routing data is retained but inactive + +Office has not been released as an enabled user workflow, so Kandev does not +translate legacy Office workspace routing into dynamic profiles. Existing +Office routing tables and rows remain intact across upgrade. New code stops +displaying and mutating those settings, and users configure dynamic profiles +when they want routing. A future cleanup requires a separate retention decision, +but this migration does not drop or rewrite the legacy rows. + +The handoff removes the live Office `StartTaskWithRoute` and `RouteOverride` +path, including legacy model, flag, and environment overlays. Dynamic routing +always launches one complete concrete profile. `KANDEV_MOCK_PROVIDERS` remains +generic E2E infrastructure, but the Office-specific routable-provider catalogue +does not remain a production routing authority. + +## Consequences + +### Positive + +- Kanban and Office share one routing model, runtime, settings surface, health + service, and observability contract. +- Office configuration becomes smaller: an agent chooses an execution agent + profile. +- One task session can survive provider changes without creating misleading + session tabs. +- The initial feature has no dependency on new plugin contracts. +- A failed account is suppressed across concurrent tasks without disabling + healthy accounts from the same provider. + +### Costs + +- The dynamic conductor becomes a stateful runtime boundary that must persist + route generation, active concrete profile, downstream session identity, and + circuit state. +- The UI needs explicit route-transition and capability-replacement events. +- Cross-provider continuation is semantic rather than lossless. It depends on + durable task and repository state plus a bounded resume prompt. +- Existing Office routing runtime ownership and settings UI must be removed + before Office is enabled. Existing table rows remain stored but inactive. + +## Alternatives Considered + +### Keep separate Kanban and Office routers + +Rejected. Profile selection has different behavior in each workspace mode. +This alternative also duplicates routing policy, health state, UI, and tests. + +### Keep routing in the workflow or Office scheduler + +Rejected. A task-level scheduler must not manage downstream ACP sessions or +provider-native resume tokens. It also creates another task session or tab +when a provider changes. + +### Treat the dynamic profile as an ordinary ACP provider + +Rejected. No downstream ACP agent can import another provider's session, and +standard ACP does not describe Kandev's logical route identity or atomic +capability replacement. The conductor must own those extensions. + +### Include plugin telemetry in the initial delivery + +Rejected for this package. Provider-error fallback and ordered routing deliver +the dynamic-profile value without a new plugin contract. Telemetry needs a +separate contract, rollout flag, persistence model, and test package. + +## Relationship to Prior Decisions + +This decision supersedes +[Separate Office identity from routed execution profiles](2026-07-15-office-agent-execution-profile-routing.md) +as the owner and scope of routing. It retains that decision's separation +between Office identity and concrete execution configuration, then adds a +dynamic routing-policy identity between them. + +This decision amends +[Separate Agent Error Evidence From Recovery Policy](2026-08-08-provider-neutral-agent-error-recovery.md): +the recovery policy is now selected by concrete versus dynamic profile kind, +not by Kanban versus Office workspace mode. + +This decision also amends +[Attribute Runtime Configuration to Turns](2026-07-18-turn-configuration-snapshots.md): +a routed turn's immutable configuration snapshot includes its concrete +execution profile and route generation. + +Product behavior is specified in +[Dynamic Agent Routing](../specs/agents/dynamic-agent-routing.md). Deferred +plugin behavior is specified separately in +[Dynamic Agent Telemetry Routing](../specs/agents/dynamic-agent-telemetry-routing.md). diff --git a/docs/decisions/2026-08-17-provider-error-classes-and-policies.md b/docs/decisions/2026-08-17-provider-error-classes-and-policies.md new file mode 100644 index 0000000000..70c261b7bc --- /dev/null +++ b/docs/decisions/2026-08-17-provider-error-classes-and-policies.md @@ -0,0 +1,200 @@ +# ADR-2026-08-17-provider-error-classes-and-policies: Classify Provider Errors Before Applying Candidate Policy + +**Status:** accepted +**Date:** 2026-08-17 +**Area:** backend, frontend, protocol, workflow + +## Context + +Kandev already normalizes provider failures into semantic codes, but recovery +policy is split across classifier-owned booleans, Kanban retry tables, Office +routing tables, and dynamic candidate maps. The dynamic map currently stores a +generic `retry_same`, `try_next`, or `stop` action by semantic code. It cannot +express a retry budget, exponential interval, bounded waiting for a provider +reset, or the outcome after retries are exhausted. + +The same selected dynamic profile can run in Kanban, a utility invocation, or +Office. If each caller maps provider strings or codes independently, the +profile's meaning changes with the workspace mode and the error catalogue +drifts. Provider and agent CLIs also report many failures as unstructured text, +so the catalogue must grow as real examples are observed without making broad +unknown matches automatically actionable. + +Automatic replay and fallback can duplicate side effects. A policy setting +must not turn ambiguous prompt delivery, assistant output, tool effects, or a +partial utility result into permission to repeat work. + +## Decision + +Kandev uses one provider-neutral classification and policy pipeline: + +1. Agent adapters produce bounded, sanitized, invocation-correlated evidence. +2. A deterministic fixture-driven catalogue assigns a stable semantic code, + one policy class, confidence, scope, rule ID, catalogue version, and + validated timing hints. +3. A shared effect-safety gate decides whether automatic recovery is allowed. +4. A shared policy evaluator applies the selected candidate's class policy and + returns a durable route transition. +5. Kanban, utility, workflow, and Office owners present or schedule that + transition without reclassifying the error. + +### Two configurable classes + +Recoverable provider errors belong to one of two user-configurable classes: + +- `transient`: temporary network, transport, provider availability, overload, + selected-model capacity, and confirmed short throttling failures. +- `hard`: account, subscription, quota, credentials, provider configuration, + or selected-model failures that need a longer reset or user/configuration + change. + +Semantic codes remain stable diagnostic detail and can grow independently. +Task, repository, permission, local runtime, cancellation, and resume-state +failures are outside provider policy. + +Unknown, low-confidence, stale, or conflicting evidence is `unclassified`. +This is an internal classification state, not a third configurable class. It +always fails closed to manual recovery. A new semantic code without an explicit +class assignment also classifies as unclassified until exhaustive tests and +the catalogue are updated. + +### Policy is a pipeline, not one action + +Each dynamic candidate stores a complete policy for each class: + +- optional reset waiting with a maximum accepted wait, +- optional same-candidate retry with maximum additional retries and an initial + interval that doubles exponentially, and +- an explicit exhausted outcome of `skip` or `stop`. + +Evaluation first applies a trusted future `retry_after` or `reset_at` when it is +within the configured maximum. Each candidate and class can use that reset wait +once per route cycle. If no eligible wait applies, the evaluator uses the next +retry interval while budget remains. When recovery is exhausted, it skips the +candidate or stops according to the configured outcome. The reset wait and its +one post-wait attempt do not consume the exponential retry budget. + +This policy shape avoids an implicit answer to "what happens after retry?" and +supports immediate skip or stop by disabling both wait and retry. + +### Safety and durable ownership override policy + +The policy evaluator can run only for current, effect-safe evidence. User +configuration cannot override generation fencing, continuation requirements, +or replay safety. Ambiguous work stops even when the policy requests retry or +skip. + +Backend-owned route state persists the classification, policy snapshot, retry +ordinal, absolute deadline, and pending exhausted outcome before scheduling. +Timers, manual actions, and provider events use one generation-fenced +transition owner. Restart reconciliation re-arms only work proven not to have +dispatched. + +### One catalogue and one evaluator across modes + +`internal/agent/runtime/routingerr` owns the evidence and deterministic +catalogue contract. A provider-neutral runtime policy package owns class-policy +validation and evaluation. Dynamic routing consumes it directly. Kanban, +utility calls, and Office supply invocation and effect context, but do not own +provider allow-lists or duplicate policy schedules. + +Concrete-profile interactive recovery may keep different product defaults from +dynamic profiles. It still consumes the shared class and timing metadata. + +### Compatibility + +The versioned dynamic candidate document replaces generic action maps. Existing +rules are normalized when read or updated: + +- `try_next` becomes immediate `skip` for both classes. +- `stop` becomes immediate `stop` for both classes. +- `retry_same` becomes one retry after 5 seconds followed by `stop`. +- Explicit per-code rules are mapped to the class assigned to that code, with a + validation error when conflicting code rules would produce two policies for + one class. + +New candidates use immediate `skip` for both classes. This preserves current +fallback behavior and does not introduce hidden retries or waits. + +### Catalogue growth and future classifiers + +Catalogue rules have stable IDs, explicit priority, bounded input, and +sanitized positive, negative, correlation, and redaction fixtures. Historical +attempts keep the code, class, rule ID, and catalogue version selected at the +time. + +A future model-based classifier can be considered as another evidence-to- +classification producer with provenance and confidence. This decision does +not authorize network calls, model-authored policy, or automatic recovery from +model-only classification. That work requires a separate decision and rollout. + +## Consequences + +### Positive + +- A dynamic profile has the same recovery meaning in Kanban and Office. +- Users can configure bounded retry, near-reset waiting, skip, and stop without + per-provider rules. +- Unknown strings are safe by default while the catalogue can grow through + fixtures. +- Retry exhaustion, timer ownership, and restart behavior are explicit. +- Provider adapters and UIs do not duplicate recovery tables. + +### Costs + +- Dynamic policy persistence and route state need a versioned migration. +- The settings editor becomes denser and needs progressive disclosure on + desktop and phone. +- Existing Kanban and Office policy tables must converge on the shared class + contract without changing effect-safety boundaries. +- Durable retry and reset waiting require scheduler reconciliation and race + tests. + +## Alternatives Considered + +### Keep rules keyed only by semantic code + +Rejected. It exposes catalogue churn to users, creates repetitive settings, +and still leaves retry schedules and exhaustion undefined. + +### Keep a single action per class + +Rejected. `retry` cannot say what follows exhaustion, and `wait` cannot say +what happens when a reset is absent or beyond the maximum. + +### Let Kanban and Office keep separate policy tables + +Rejected. The same dynamic profile would behave differently by caller and new +error codes would require synchronized allow-lists. + +### Treat unknown errors as transient + +Rejected. Broad automatic replay or provider switching can duplicate effects +and hides classifier gaps. + +### Use a model to classify unknown errors now + +Deferred. It adds latency, cost, privacy, availability, prompt-injection, and +confidence-calibration concerns. Deterministic classification and a growing +fixture catalogue are required first. + +## Relationship to Prior Decisions + +This decision amends +[Separate Agent Error Evidence From Recovery Policy](2026-08-08-provider-neutral-agent-error-recovery.md). +It retains the evidence and semantic-code boundary, replaces workspace-owned +provider allow-lists with shared classes, and keeps product-specific defaults +outside the classifier. + +It amends +[Unify Provider Routing Behind Dynamic Agent Profiles](2026-08-13-dynamic-agent-profile-routing.md) +by replacing generic candidate error actions with versioned per-class policy. + +It supersedes the fixed transient attempt schedule in +[Retry transient provider errors with backend-owned exponential backoff](0011-transient-provider-error-retry.md) +for dynamic profiles. Concrete-profile interactive retry can retain that +decision's defaults while consuming the shared classification contract. + +Product behavior is specified in +[Provider Error Recovery](../specs/platform/provider-error-recovery.md) and +[Dynamic Agent Routing](../specs/agents/dynamic-agent-routing.md). diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index eea03d595f..a6ceffc2a8 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -50,7 +50,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 0042 | [Project shell output and fetch it on demand](0042-project-shell-output-and-fetch-on-demand.md) | accepted | backend, frontend, protocol | 2026-07-16 | | 0043 | [Plugins read/write kandev data via capability-gated Host gRPC RPCs](0043-plugin-host-data-api.md) | accepted | backend, protocol | 2026-07-17 | | 2026-07-14-typed-utility-chat-sessions | [Typed Utility Chats Share the Quick Chat Session Model](2026-07-14-typed-utility-chat-sessions.md) | accepted | backend, frontend | 2026-07-14 | -| 2026-07-15-office-agent-execution-profile-routing | [Separate Office identity from routed execution profiles](2026-07-15-office-agent-execution-profile-routing.md) | proposed | backend, frontend | 2026-07-15 | +| 2026-07-15-office-agent-execution-profile-routing | [Separate Office identity from routed execution profiles](2026-07-15-office-agent-execution-profile-routing.md) | superseded by 2026-08-13-dynamic-agent-profile-routing | backend, frontend | 2026-07-15 | | 0044 | [ACP agent compatibility dialects](0044-acp-agent-compatibility-dialects.md) | accepted | backend, protocol | 2026-07-16 | | 0045 | [Install-wide storage maintenance uses typed ownership providers and quarantine](0045-install-wide-storage-maintenance.md) | accepted (amended 2026-07-22) | backend, frontend, infra | 2026-07-14 | | 0046 | [Settings route save coordinator](0046-settings-route-save-coordinator.md) | accepted (amended 2026-08-09) | frontend | 2026-07-14 | @@ -68,7 +68,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-06-plugin-code-host-dashboard-parity | [Code-Host Plugins Reuse Native Dashboard Primitives](2026-08-06-plugin-code-host-dashboard-parity.md) | accepted | frontend, protocol | 2026-08-06 | | 2026-08-10-plugin-change-request-mutations | [Route Native Change-Request Mutations Through Providers](2026-08-10-plugin-change-request-mutations.md) | accepted | frontend, protocol | 2026-08-10 | | 2026-08-12-plugin-localization-contract | [Host-Scoped Plugin Localization](2026-08-12-plugin-localization-contract.md) | accepted | frontend, protocol | 2026-08-12 | -| 2026-07-18-turn-configuration-snapshots | [Attribute runtime configuration to turns](2026-07-18-turn-configuration-snapshots.md) | accepted | backend, frontend | 2026-07-18 | +| 2026-07-18-turn-configuration-snapshots | [Attribute runtime configuration to turns](2026-07-18-turn-configuration-snapshots.md) | accepted (amended 2026-08-13) | backend, frontend | 2026-07-18 | | 2026-07-22-gpg-signed-release-tags | [GPG-signed release tags](2026-07-22-gpg-signed-release-tags.md) | accepted | infra, workflow | 2026-07-22 | | 2026-07-19-reject-mcp-actions-on-raw-websocket | [Reject MCP Actions on the Raw WebSocket](2026-07-19-reject-mcp-actions-on-raw-websocket.md) | accepted | backend, protocol | 2026-07-19 | | 2026-07-19-workspace-symlink-entries | [Treat Nested Workspace Symlinks as Entries](2026-07-19-workspace-symlink-entries.md) | accepted | backend, infra | 2026-07-19 | @@ -128,9 +128,9 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-02-single-owner-agent-task-titles | [Assign Agent Task Titles to One Session](2026-08-02-single-owner-agent-task-titles.md) | accepted | backend, frontend, protocol, workflow | 2026-08-02 | | 2026-08-02-agent-terminal-diagnostics-over-stderr | [Capture Agent Terminal Diagnostics From Managed Stderr](2026-08-02-agent-terminal-diagnostics-over-stderr.md) | accepted | backend, frontend, protocol, security | 2026-08-02 | | 2026-08-07-allowlisted-provider-action-links | [Allowlisted Provider Action Links](2026-08-07-allowlisted-provider-action-links.md) | accepted | backend, frontend, protocol, security | 2026-08-07 | -| 2026-08-08-provider-neutral-agent-error-recovery | [Separate Agent Error Evidence From Recovery Policy](2026-08-08-provider-neutral-agent-error-recovery.md) | accepted | backend, frontend, protocol | 2026-08-08 | -| 2026-08-08-utility-agent-profile-execution | [Utility Agents Use Execution Profiles](2026-08-08-utility-agent-profile-execution.md) | accepted | backend, frontend, protocol | 2026-08-08 | -| 2026-08-08-utility-profile-dependency-safety | [Warn Before Breaking Utility Profile Bindings](2026-08-08-utility-profile-dependency-safety.md) | accepted | backend, frontend, protocol | 2026-08-08 | +| 2026-08-08-provider-neutral-agent-error-recovery | [Separate Agent Error Evidence From Recovery Policy](2026-08-08-provider-neutral-agent-error-recovery.md) | accepted (amended 2026-08-13 and 2026-08-17) | backend, frontend, protocol | 2026-08-08 | +| 2026-08-08-utility-agent-profile-execution | [Utility Agents Use Execution Profiles](2026-08-08-utility-agent-profile-execution.md) | accepted (amended 2026-08-14) | backend, frontend, protocol | 2026-08-08 | +| 2026-08-08-utility-profile-dependency-safety | [Warn Before Breaking Utility Profile Bindings](2026-08-08-utility-profile-dependency-safety.md) | accepted (amended 2026-08-14) | backend, frontend, protocol | 2026-08-08 | | 2026-08-02-class-aware-git-subprocess-admission | [Class-Aware Git Subprocess Admission](2026-08-02-class-aware-git-subprocess-admission.md) | accepted | backend, agentctl, observability | 2026-08-02 | | 2026-08-02-isolate-replaceable-session-stream-traffic | [Isolate Replaceable Session Stream Traffic](2026-08-02-isolate-replaceable-session-stream-traffic.md) | accepted | backend, frontend, protocol | 2026-08-02 | | 2026-08-03-backend-owned-cancellation-progress | [Keep Cancellation Progress Backend Owned](2026-08-03-backend-owned-cancellation-progress.md) | accepted | backend, frontend, protocol | 2026-08-03 | @@ -165,6 +165,8 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-10-remote-contribution-head-drift | [Separate Current Contribution and Local Checkout Histories](2026-08-10-remote-contribution-head-drift.md) | accepted (remote-action policy superseded, provider-error presentation amended) | backend, frontend, protocol, GitHub, GitLab | 2026-08-10 | | 2026-08-12-local-first-contribution-replacement | [Keep Remote Contribution Work Local-First](2026-08-12-local-first-contribution-replacement.md) | accepted | backend, frontend, protocol, security, GitHub, GitLab | 2026-08-12 | | 2026-08-13-provider-history-changes-enrichment | [Treat Provider History as Changes Enrichment](2026-08-13-provider-history-changes-enrichment.md) | accepted | frontend, GitHub, GitLab | 2026-08-13 | +| 2026-08-13-dynamic-agent-profile-routing | [Unify Provider Routing Behind Dynamic Agent Profiles](2026-08-13-dynamic-agent-profile-routing.md) | accepted (amended 2026-08-17) | backend, frontend, protocol, workflow | 2026-08-13 | +| 2026-08-17-provider-error-classes-and-policies | [Classify Provider Errors Before Applying Candidate Policy](2026-08-17-provider-error-classes-and-policies.md) | accepted | backend, frontend, protocol, workflow | 2026-08-17 | | 2026-08-11-user-owned-status-bar-visibility | [Make Status Bar Visibility a Portable User Preference](2026-08-11-user-owned-status-bar-visibility.md) | accepted | backend, frontend, protocol | 2026-08-11 | | 2026-08-12-built-in-utility-default-inheritance | [Built-in Utility Actions Inherit the Global Profile by Default](2026-08-12-built-in-utility-default-inheritance.md) | superseded by 2026-08-12-empty-utility-bindings-inherit-default | backend, frontend | 2026-08-12 | | 2026-08-12-empty-utility-bindings-inherit-default | [Empty Built-in Utility Bindings Inherit the Default](2026-08-12-empty-utility-bindings-inherit-default.md) | accepted | backend, frontend | 2026-08-12 | diff --git a/docs/plans/dynamic-agent-routing-rollout-blockers/plan.md b/docs/plans/dynamic-agent-routing-rollout-blockers/plan.md new file mode 100644 index 0000000000..b65883780a --- /dev/null +++ b/docs/plans/dynamic-agent-routing-rollout-blockers/plan.md @@ -0,0 +1,57 @@ +--- +spec: docs/specs/agents/dynamic-agent-routing-rollout-blockers.md +created: 2026-08-15 +status: completed +--- + +# Plan: Dynamic Agent Routing Rollout Blockers + +This is a repair package for the in-progress +[Dynamic Agent Routing](../dynamic-agent-routing/plan.md) plan. It is limited +to the seven rollout blockers and two review suggestions. Telemetry paths stay +untracked and untouched. + +## Work packages + +1. [Continuation-safe fallback](task-01-continuation-safe-fallback.md) + adds effect evidence, fail-closed classification, bounded durable + continuation delivery, and all-candidate launch fallback. +2. [Atomic route actions](task-02-atomic-route-actions.md) moves Retry and Try + next handoff ownership into one backend operation. +3. [Utility and shared health](task-03-utility-and-shared-health.md) isolates + utility route identities, rejects partial results, wires binding health, + circuit opening, and exclusive probes. +4. [Settings and picker contract](task-04-settings-and-picker-contract.md) + restores the Dynamic profile list, complete candidate rules, profile kind + propagation, flag-off filtering, and the duplicate-action guard. + +## Verification gate + +- Focused Go tests cover each package before production changes are retained. +- Focused web tests cover profile option filtering and dynamic editor state. +- `gofmt`, backend package tests, web typecheck, i18n checks, and the relevant + settings tests pass before this package is marked complete. +- The original dynamic-routing task files are updated with the repair results. + +## Results + +Completed on 2026-08-15. The seven rollout blockers and two review +suggestions are implemented. Telemetry-routing files remain separate and were +not included in the implementation. + +Verification: + +- `go test ./internal/orchestrator ./internal/task/repository/sqlite ./internal/agent/runtime/dynamic ./internal/backendapp ./internal/utility/...` passed: 2695 tests in 13 packages. +- Focused web routing, settings, picker, utility, handoff, quick-chat, and session tests passed: 61 tests in 11 files. +- Backend `make lint` and web `pnpm run lint` passed with zero issues and zero warnings. +- `pnpm run typecheck` passed. +- `pnpm run i18n:check` passed with the repository's existing advisory catalog parity warnings. +- `pnpm run i18n:ratchet` passed. +- `node --test scripts/validate-public-docs.test.mjs` passed: 61 tests. +- `gofmt` and `git diff --check` passed. + +## Out of scope + +The Office routing handoff, observability migration, dedicated dynamic-routing +Playwright projects, and `dynamic-agent-telemetry-routing` package remain +pending in their original plan. diff --git a/docs/plans/dynamic-agent-routing-rollout-blockers/task-01-continuation-safe-fallback.md b/docs/plans/dynamic-agent-routing-rollout-blockers/task-01-continuation-safe-fallback.md new file mode 100644 index 0000000000..d11c75a7b1 --- /dev/null +++ b/docs/plans/dynamic-agent-routing-rollout-blockers/task-01-continuation-safe-fallback.md @@ -0,0 +1,27 @@ +--- +id: "dynamic-routing-blockers-01" +title: "Continuation-safe fallback" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/agents/dynamic-agent-routing-rollout-blockers.md" +--- + +# Task 01: Continuation-safe fallback + +Add regression coverage for partial output and tool-effect failures, then make +the conductor build, persist, and deliver bounded continuation data. Walk all +eligible candidates with generation fencing and never transfer a provider +native ACP identity across profiles. + +**Verification:** dynamic engine and conductor tests plus focused orchestrator +failure tests. + +## Results + +Completed. Dynamic attempts now require explicit no-output/no-effect evidence; +ambiguous and stale execution events fail closed. The conductor persists and +delivers bounded provider-neutral continuation packages, fences native ACP +identity, and walks every eligible candidate. Focused dynamic and orchestrator +tests passed. diff --git a/docs/plans/dynamic-agent-routing-rollout-blockers/task-02-atomic-route-actions.md b/docs/plans/dynamic-agent-routing-rollout-blockers/task-02-atomic-route-actions.md new file mode 100644 index 0000000000..dccb49a629 --- /dev/null +++ b/docs/plans/dynamic-agent-routing-rollout-blockers/task-02-atomic-route-actions.md @@ -0,0 +1,24 @@ +--- +id: "dynamic-routing-blockers-02" +title: "Atomic route actions" +status: completed +wave: 2 +depends_on: ["dynamic-routing-blockers-01"] +plan: "plan.md" +spec: "../../specs/agents/dynamic-agent-routing-rollout-blockers.md" +--- + +# Task 02: Atomic route actions + +Make the route-action backend own selection, predecessor shutdown, successor +launch, and final state persistence. Remove the frontend's second launch +request and retain recovery controls after a failed handoff. + +**Verification:** backend route-action tests and a frontend request-count test. + +## Results + +Completed. Retry and Try next now use one backend-owned route action. The +backend launches the successor and records an actionable waiting state when +launch fails; the frontend no longer sends a second `session.launch` request. +The route-action lifecycle suite and focused web tests passed. diff --git a/docs/plans/dynamic-agent-routing-rollout-blockers/task-03-utility-and-shared-health.md b/docs/plans/dynamic-agent-routing-rollout-blockers/task-03-utility-and-shared-health.md new file mode 100644 index 0000000000..9aa367baeb --- /dev/null +++ b/docs/plans/dynamic-agent-routing-rollout-blockers/task-03-utility-and-shared-health.md @@ -0,0 +1,25 @@ +--- +id: "dynamic-routing-blockers-03" +title: "Utility and shared health" +status: completed +wave: 3 +depends_on: ["dynamic-routing-blockers-01"] +plan: "plan.md" +spec: "../../specs/agents/dynamic-agent-routing-rollout-blockers.md" +--- + +# Task 03: Utility and shared health + +Give every utility invocation an isolated route ID, stop fallback after partial +results, connect concrete binding descriptors to candidates, open circuits on +qualifying failures, and make expired circuits use production probe leases. + +**Verification:** resolver, utility handler, circuit, and engine tests. + +## Results + +Completed. Utility calls use fresh transient identities and reject partial +results before fallback. Concrete credential bindings now feed shared circuit +keys, qualifying failures open scoped circuits, and expired circuits use +exclusive production probes. Focused utility, engine, resolver, and SQLite +tests passed. diff --git a/docs/plans/dynamic-agent-routing-rollout-blockers/task-04-settings-and-picker-contract.md b/docs/plans/dynamic-agent-routing-rollout-blockers/task-04-settings-and-picker-contract.md new file mode 100644 index 0000000000..c83843cf0d --- /dev/null +++ b/docs/plans/dynamic-agent-routing-rollout-blockers/task-04-settings-and-picker-contract.md @@ -0,0 +1,28 @@ +--- +id: "dynamic-routing-blockers-04" +title: "Settings and picker contract" +status: completed +wave: 4 +depends_on: ["dynamic-routing-blockers-02", "dynamic-routing-blockers-03"] +plan: "plan.md" +spec: "../../specs/agents/dynamic-agent-routing-rollout-blockers.md" +--- + +# Task 04: Settings and picker contract + +Render all dynamic profiles with create and direct-edit actions. Add profile +and candidate enablement plus provider-error action controls. Preserve profile +kind in picker options, centrally filter dynamic profiles when the feature is +off, and hide unsupported duplication. + +**Verification:** focused web unit/component tests, typecheck, i18n checks, and +the existing mobile settings flow. + +## Results + +Completed. Settings renders every dynamic profile with creation and editing +controls, candidate enablement, ordering, and provider-error actions. Profile +kind is preserved and dynamic profiles are excluded from new selections when +the flag is off; duplicate is hidden for dynamic profiles. The editor keeps +44px touch targets and uses the existing mobile picker sheet. Focused web +tests, typecheck, i18n checks, and the ratchet passed. diff --git a/docs/plans/dynamic-agent-routing/plan.md b/docs/plans/dynamic-agent-routing/plan.md new file mode 100644 index 0000000000..21aa1e7058 --- /dev/null +++ b/docs/plans/dynamic-agent-routing/plan.md @@ -0,0 +1,323 @@ +--- +spec: docs/specs/agents/dynamic-agent-routing.md +created: 2026-08-14 +status: in_progress +--- + +# Implementation Plan: Dynamic Agent Routing + +## Overview + +Ship the guarded virtual profile family, transparent execution resolver, +fixed-order and error-based routing, durable conductor, shared health, and +Kanban, utility, and Office surfaces. Plugin cost and subscription telemetry +belong to a separate deferred package and are not implementation tasks here. + +The follow-up [Provider Error Policies](../provider-error-policies/plan.md) +package replaces the generic candidate action map with shared transient and +hard policies, durable reset waits, bounded exponential retry, and explicit +skip/stop exhaustion. This package retains the compatibility action path until +that migration lands. + +--- + +## Backend + +### Rollout boundary + +- Add `features.dynamicAgentRouting` / `KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING` + through `profiles.yaml`, `internal/common/config`, and `internal/runtimeflags`. +- Register it as mutable, experimental, high risk, and restart-required. + Default every shipped profile to false. Gate profile mutation, dynamic + execution, route actions, background probes, and capability advertisement at + backend composition boundaries. +- Preserve stored dynamic configuration while disabled. Persisted dynamic + sessions enter `action_required` without launch or resume. Concrete Kanban, + utility, and Office execution remains unchanged. Add the explicit + `office=on` and `dynamic=off` test cell. + +### Virtual family and profile persistence + +- Add a non-launchable `dynamic` built-in agent implementation under + `internal/agent/agents` and register it unconditionally in + `internal/agent/registry`. Keep it out of `ListInferenceAgentsWithContext` + while keeping its DB parent row safe from orphan reconciliation. +- Extend `settings/models.AgentProfile` and DTOs with computed profile kind. + Persist one-to-one configuration in `dynamic_agent_profiles` and ordered + candidates in `dynamic_agent_routes` through + `internal/agent/settings/store/sqlite.go`, do not add a duplicate kind column. +- Extend profile CRUD and typed dependency conflicts for dynamic configuration, + candidate references, optimistic versioning, and confirmed stale-reference + semantics. +- Treat profile kind as execution-family identity. Validate only launchable + concrete profiles as candidates. Exclude rich Office identities, + `AutoFallback=true` profiles, and dynamic profiles. Allow explicit + `FallbackModel` as intra-profile policy. + +### Core route engine + +- Create `internal/agent/runtime/dynamic` for route selection, route state, + fixed order, compatibility error actions, shared resource circuits, probe leases, + and generation fencing. Reuse `runtime/routingerr` classification and migrate + reusable Office backoff behavior without importing `internal/office`. +- Add a concrete-adapter `CredentialBindingDescriptor` contract after launch + environment resolution. Add `CredentialBindingResolver` to canonicalize the + descriptor and derive an HMAC-SHA-256 fingerprint with a persistent Kandev + installation key. Use provider, binding, model, or profile scopes. Unknown + bindings fall back to profile scope. +- Add shared SQLite repositories for route attempts, logical route state, and + resource health under `internal/task/repository/sqlite/base_schema.go`, + `base_migrations.go`, and focused repository files. The first committed route + generation owns successor launch, stale writers fail closed. Candidate + selection uses generation compare-and-swap, not an exclusive candidate lease. + +### ACP conductor and continuation + +- Add a profile-execution resolver above `runtime.Runtime`. Concrete selections + delegate unchanged, dynamic selections create a logical conductor that owns + downstream `Runtime.Launch`, `Resume`, `Stop`, and event subscriptions. +- Persist downstream ACP identity with its concrete profile. Same-profile resume + can reuse it, cross-profile fallback always creates a fresh ACP session. +- Build bounded continuation from durable task, conversation, plan, tool, and + repository state. Fence potentially effectful attempts before a successor. + +### Task, workflow, and utility integration + +- Keep `TaskSession.AgentProfileID` logical and + `TaskSession.ExecutionProfileID` concrete. Add route generation and concrete + attribution to turn snapshots and structured route-reason events. +- Route task/workflow launch through the shared resolver without caller kind + branches. Replace live model, mode, configuration-option, and custom-command + capabilities by route generation. +- Add the generation-fenced `session.route_action` WebSocket request. `retry` + evaluates the full policy. `try_next` excludes the current candidate for one + decision. Both return the authoritative route snapshot on stale input. +- Allow utility bindings to resolve dynamic profiles. Add + `utility_agent_calls.execution_profile_id`, pre-result classified failures can + select another candidate, while ambiguous effectful results fail closed. + +### Office handoff + +- Add `agent_profiles.execution_agent_profile_id` as a validated soft reference + for Office identities and route Office launches through the shared resolver. +- Stop registering or calling Office routing mutation endpoints, onboarding + seeding, scheduler router, and Office-specific routing UI. Preserve + `office_workspace_routing`, `office_run_route_attempts`, and + `office_provider_health` tables and rows unchanged and unread. +- Remove the Office `StartTaskWithRoute` and `RouteOverride` chain through the + scheduler, orchestrator, executor, backend adapters, and lifecycle manager. + Remove the legacy model, flag, and environment overlay helpers and tests. + Keep `KANDEV_MOCK_PROVIDERS` as generic E2E infrastructure. Remove the + Office-specific production provider catalogue. +- Retain Office wake-reason selection only as a request for another concrete or + dynamic execution profile. Provider policy stays inside the selected dynamic + profile. + +### Observability migration + +- Move `routing_route_attempts_total` and `routing_fallback_total` to the shared + router with their current meaning. +- Retire `routing_provider_degraded_total` and + `routing_provider_recovered_total`. Replace them with + `routing_resource_circuit_opened_total` and + `routing_resource_circuit_closed_total`. +- Retire `routing_parked_runs_total`. Replace it with + `routing_waiting_sessions_total`. +- Apply the same mapping to `routing.metric.*` structured log events. Update + root and backend `AGENTS.md` observability guidance. Do not emit raw account + identifiers, credentials, prompts, or unbounded profile names. + +## Frontend + +### Profile settings + +- Extend agent profile types/API normalization with `kind`, dynamic candidates, + version, compatibility provider-error actions, and typed dependency details. +- Add the Dynamic family section and dedicated profile route. Desktop uses the + settings-page editor, phone uses direct navigation with one internal scroll + owner. Candidate selection uses the existing inset mobile picker drawer. + Reorder actions remain visible and at least 44px. + +### Routed chat + +- Store route generation, current concrete profile, route state, and + authoritative capabilities in the session slice. Discard stale generations. +- Keep the logical tab/author identity, render immutable concrete provider/model + badges per turn, localize reason enums into durable route rows, and replace + provider controls without remounting the composer. +- Render waiting and action-required states with generation-current Retry and + Try next actions. The phone chat keeps these actions inline, uses the existing + scroll owner, and does not replace or hide the composer. + +### Utility and Office selectors + +- Include eligible dynamic profiles in utility and Office execution-profile + pickers without changing their saved logical ID shape. +- Remove legacy Office routing navigation and editors while leaving unrelated + Office surfaces intact. Show shared route status through task/run read models. + +### Mobile design contract + +- Agent settings uses its existing tappable profile row and direct profile + route. The dense editor remains a dedicated page with one scroll owner. +- Temporary candidate choices use the inset `MobilePickerSheet` pattern. Move + controls remain visible, touch targets are at least 44px, and the page clears + the safe area without horizontal document overflow. +- Desktop and mobile share state, validation, ordering, and actions. Only the + profile editor and temporary picker composition differ. + +## Public documentation + +- Update `docs/public/agents-and-profiles.md` with dynamic profile creation, + candidate behavior, and recovery actions. +- Update `docs/public/configuration.md` and `docs/public/feature-status.md` with + the runtime flag, defaults, restart requirement, and experimental status. +- Update `docs/public/tasks-and-workflows.md` with dynamic workflow selection + and one-logical-session behavior. + +## Tests + +- **Profile identity:** registry, reconciler, SQLite migration, DTO, CRUD, and + dependency tests prove the virtual family survives boot and cannot launch. +- **Core routing:** table-driven engine tests cover ordering, error actions, + binding derivation, provider/binding/model/profile circuit scope, generation + claims, exclusive probe leases, waiting, actions, and recovery. +- **Conductor:** integration tests cover same-route resume, cross-route fresh + ACP sessions, continuation, restart reconciliation, and stale frame fencing. +- **Callers:** orchestrator, workflow, utility, plugin task-launch, and Office + tests prove callers submit one logical profile ID and receive concrete + attribution behind the resolver. +- **Persistence:** real SQLite tests prove route/turn/utility attribution and + preservation of all legacy Office routing rows. + +## E2E Tests + +- `apps/web/e2e/tests/settings/dynamic-agent-profile.spec.ts` covers desktop + create/edit/reorder/dependency behavior. +- `apps/web/e2e/tests/settings/mobile-dynamic-agent-profile.spec.ts` covers the + direct phone route, picker drawer, touch reorder actions, internal scrolling, + safe-area clearance, and no document overflow. +- Add isolated `dynamic-routing` and `dynamic-routing-mobile` Playwright + projects for specs that restart with `KANDEV_MOCK_PROVIDERS`. Keep settings + specs in the normal desktop and mobile projects. +- Match these desktop files only in `dynamic-routing`: + `tests/task/dynamic-agent-routing.spec.ts`, + `tests/task/dynamic-agent-routing-rollout.spec.ts`, + `tests/workflow/dynamic-agent-profile.spec.ts`, + `tests/settings/dynamic-utility-profile.spec.ts`, and + `tests/office/dynamic-agent-execution-profile.spec.ts`. +- Match these phone files only in `dynamic-routing-mobile`: + `tests/task/mobile-dynamic-agent-routing.spec.ts` and + `tests/office/mobile-dynamic-agent-execution-profile.spec.ts`. Exclude all + seven files from the default Chromium and mobile projects. +- `apps/web/e2e/tests/task/dynamic-agent-routing.spec.ts` covers one-tab fallback, + localized route rows, immutable badges, capability replacement, waiting, + generation-fenced actions, and restart recovery with multi-provider mocks. +- `apps/web/e2e/tests/task/mobile-dynamic-agent-routing.spec.ts` proves the same + chat outcome without composer loss on the Pixel 5 project. +- Utility, workflow, Office, and flag-matrix specs prove transparent selection, + retained legacy rows, and fail-closed disabled behavior. Retire the five + `office-routing-*` specs and `mobile-provider-routing-profiles.spec.ts` with + the legacy UI. + +## Verification Results + +Design-package validation on 2026-08-15: + +- 16 task files exist and the plan links each file once. +- Every `depends_on` ID resolves to a task in this package. +- Every task has explicit inputs and an output contract. +- Task dependencies are acyclic. The caller, documentation, and Office rollout + gates complete this package. +- Package files contain no stale task names, conflict markers, trailing spaces, + Unicode em dashes, or prohibited shorthand found by the documentation scan. + +Implementation slice validated on 2026-08-15: + +- Task 01 flag tests pass with production, development, and E2E defaults off. +- Tasks 02 and 03 profile-family, persistence, CRUD, dependency, and + optimistic-update tests pass with the `fts5` build tag. +- Task 04 dynamic engine and SQLite route-state tests pass, including fixed + ordering, semantic actions, binding fingerprints, circuit/probe contracts, + generation claims, restart loading, and stale-writer rejection. +- Task 05 and Task 06 resolver, launch-boundary, logical-session, settled-turn + route-action, turn-attribution, persistence, continuation, and fail-closed + evidence changes compile and pass the focused orchestrator/backend suite. + The rollout-blocker repair also makes route actions atomic and delivers + durable continuation before supported mid-turn fallback. Full lifecycle + ownership, event-subscription wiring, and restart reconciliation remain + open. +- Task 07 utility routing passes its focused backend suite. Utility calls keep + logical attribution, execute the concrete profile, use isolated invocation + identities, and can advance only classified pre-result failures. Plugin-host + utility wiring and full caller E2E coverage remain open. +- Task 08 and Task 09 settings, route state, recovery UI, picker filtering, + typecheck, i18n, and focused frontend tests pass. Full immutable per-turn + badge presentation and Playwright coverage remain open. +- Task 15 public documentation validation passes after documenting the flag, + fixed-order behavior, recovery boundary, and restart requirement. +- Tasks 10 through 14 and 16 remain pending because Office handoff, + observability migration, and dedicated E2E projects were not implemented in + this slice. + +The telemetry-routing and provider-error-policy plans remain separate and are +not implemented by this package. The remaining dynamic-routing work is +explicitly tracked in the task results below rather than represented as a +completed package. + +## Implementation Waves And Parallel Candidates + +Dynamic agent routing: + +Wave 1: + +- [x] [Task 01: Runtime rollout flag](task-01-runtime-rollout-flag.md) + +Wave 2: + +- [x] [Task 02: Virtual profile foundation](task-02-virtual-profile-foundation.md) + +Wave 3: + +- [x] [Task 03: Dynamic profile management](task-03-dynamic-profile-management.md) + +Wave 4 (parallel candidates with user authorization): + +- [ ] [Task 04: Core route engine](task-04-core-route-engine.md) +- [x] [Task 08: Dynamic profile settings](task-08-dynamic-profile-settings.md) + +Wave 5 (parallel candidates with user authorization): + +- [ ] [Task 05: ACP conductor](task-05-acp-conductor.md) +- [ ] [Task 12: Profile settings E2E](task-12-profile-settings-e2e.md) + +Wave 6: + +- [ ] [Task 06: Logical session integration](task-06-logical-session-integration.md) + +Wave 7 (parallel candidates with user authorization): + +- [ ] [Task 07: Utility profile integration](task-07-utility-profile-integration.md) +- [ ] [Task 09: Routed chat presentation](task-09-routed-chat-presentation.md) + +Wave 8: + +- [ ] [Task 10: Office routing handoff](task-10-office-routing-handoff.md) + +Wave 9: + +- [ ] [Task 11: Core routing observability](task-11-core-routing-observability.md) + +Wave 10 (parallel candidates with user authorization): + +- [ ] [Task 13: Routed session E2E](task-13-routed-session-e2e.md) +- [ ] [Task 15: Core routing public docs](task-15-core-routing-public-docs.md) + +Wave 11: + +- [ ] [Task 14: Caller selection E2E](task-14-caller-selection-e2e.md) +- [ ] [Task 16: Office rollout E2E](task-16-office-rollout-e2e.md) + +Sequential execution remains the default. The wave labels expose only the two +disjoint frontend/backend or docs/test pairs. Completion of Wave 11 completes +this implementation package. diff --git a/docs/plans/dynamic-agent-routing/task-01-runtime-rollout-flag.md b/docs/plans/dynamic-agent-routing/task-01-runtime-rollout-flag.md new file mode 100644 index 0000000000..aa306fc02f --- /dev/null +++ b/docs/plans/dynamic-agent-routing/task-01-runtime-rollout-flag.md @@ -0,0 +1,46 @@ +--- +id: "01-runtime-rollout-flag" +title: "Runtime rollout flag" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/agents/dynamic-agent-routing.md" +--- + +# Task 01: Runtime rollout flag + +- **Acceptance:** Register `features.dynamicAgentRouting` and + `KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING` as mutable, experimental, high risk, + and restart-required. Default prod, dev, and E2E to false. Prove that all + disabled entry points fail before side effects while concrete launches remain + unchanged. +- **Files likely touched:** `profiles.yaml`, + `apps/backend/internal/common/config/config.go`, + `apps/backend/internal/runtimeflags/{registry,config}*.go`, + `apps/web/lib/state/slices/features/*`. +- **Dependencies:** None. +- **Parallelism:** sequential. +- **Inputs:** Spec Delivery and rollout, the `/runtime-feature-flags` checklist, + `profiles.yaml`, and `runtimeflags/registry.go` definitions. +- **Output contract:** Report the flag identity, registry metadata, profile + defaults, gated entry points, exact commands and results, blockers, risks, + and synchronized task and plan status. +- **Verification:** `make -C apps/backend lint && cd apps/backend && go test ./internal/runtimeflags ./internal/common/config ./internal/profiles && cd ../../apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- lib/state/slices/features/features-contract.test.ts && cd web && pnpm run typecheck` +- **Risks:** Every backend entry point must be gated. Frontend hiding alone is + insufficient. + +## Results + +Completed. Registered `features.dynamicAgentRouting` and +`KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING` as mutable, experimental, high-risk, +and restart-required. Production, development, and E2E profile defaults are +off. Backend validation blocks disabled dynamic profile mutation and launch +before side effects while concrete profiles retain their existing path. + +Verification: + +- `go test ./internal/runtimeflags ./internal/profiles ./internal/common/config` +- `pnpm --filter @kandev/web test -- --run lib/state/slices/features/features-contract.test.ts` + +Both commands passed. Full E2E flag-matrix coverage remains pending. diff --git a/docs/plans/dynamic-agent-routing/task-02-virtual-profile-foundation.md b/docs/plans/dynamic-agent-routing/task-02-virtual-profile-foundation.md new file mode 100644 index 0000000000..636fb86915 --- /dev/null +++ b/docs/plans/dynamic-agent-routing/task-02-virtual-profile-foundation.md @@ -0,0 +1,42 @@ +--- +id: "02-virtual-profile-foundation" +title: "Virtual profile foundation" +status: completed +wave: 2 +depends_on: ["01-runtime-rollout-flag"] +plan: "plan.md" +spec: "../../specs/agents/dynamic-agent-routing.md" +--- + +# Task 02: Virtual profile foundation + +- **Acceptance:** Seed the permanent `dynamic` family before reconciliation, + keep it out of concrete inference probing, expose computed profile kind, and + persist versioned dynamic configuration plus ordered concrete candidates. +- **Files likely touched:** `apps/backend/internal/agent/agents/dynamic.go`, + `apps/backend/internal/agent/registry/registry.go`, + `apps/backend/internal/agent/settings/models/models.go`, + `apps/backend/internal/agent/settings/dto/dto.go`, + `apps/backend/internal/agent/settings/store/sqlite.go`, + `apps/backend/internal/agent/settings/controller/reconciler*.go`. +- **Dependencies:** Task 01. +- **Parallelism:** sequential. +- **Inputs:** Spec Profile configuration and Data model, Task 01 flag contract, + current agent registry, settings SQLite schema, and reconciler patterns. +- **Output contract:** Report the seeded family invariant, schema changes, + files changed, exact commands and results, blockers, risks, and synchronized + task and plan status. +- **Verification:** `cd apps/backend && go test -tags fts5 ./internal/agent/registry/... ./internal/agent/settings/...` +- **Risks:** The family must survive orphan cleanup without becoming launchable or receiving a default concrete profile. + +## Results + +Completed. Added the permanent non-launchable `dynamic` family, computed +profile-kind normalization, versioned dynamic configuration, ordered route +persistence, and reconciliation safeguards. + +Verification: + +- `go test -tags fts5 ./internal/agent/registry/... ./internal/agent/settings/...` + +The command passed. Dedicated migration and E2E coverage remain pending. diff --git a/docs/plans/dynamic-agent-routing/task-03-dynamic-profile-management.md b/docs/plans/dynamic-agent-routing/task-03-dynamic-profile-management.md new file mode 100644 index 0000000000..c1cbf5470a --- /dev/null +++ b/docs/plans/dynamic-agent-routing/task-03-dynamic-profile-management.md @@ -0,0 +1,44 @@ +--- +id: "03-dynamic-profile-management" +title: "Dynamic profile management" +status: completed +wave: 3 +depends_on: ["02-virtual-profile-foundation"] +plan: "plan.md" +spec: "../../specs/agents/dynamic-agent-routing.md" +--- + +# Task 03: Dynamic profile management + +- **Acceptance:** Add transactional dynamic CRUD, validation, optimistic + versions, and typed dependency conflicts, confirmed disable/delete retains + stale logical and candidate references under existing profile-in-use policy. + Reject dynamic, rich Office, non-launchable, and `AutoFallback=true` + candidates while allowing explicit `FallbackModel` policy. +- **Files likely touched:** `apps/backend/internal/agent/settings/controller/profile_crud*.go`, + `apps/backend/internal/agent/settings/handlers/*profile*.go`, + `apps/backend/internal/agent/settings/store/*`, + `apps/backend/internal/agent/settings/dto/dto.go`. +- **Dependencies:** Task 02. +- **Parallelism:** sequential. +- **Inputs:** Spec Profile configuration, Transparent profile execution, and + Dynamic profile configuration, Tasks 01 and 02, existing profile dependency + and optimistic-update patterns. +- **Output contract:** Report validation rules, dependency behavior, files + changed, exact commands and results, blockers, risks, and synchronized task + and plan status. +- **Verification:** `cd apps/backend && go test -tags fts5 ./internal/agent/settings/controller/... ./internal/agent/settings/handlers/... ./internal/agent/settings/store/...` +- **Risks:** Force confirmation must not bypass dependency lookup failures or silently rewrite bindings. + +## Results + +Completed. Added transactional dynamic CRUD, candidate validation, optimistic +versions, typed dependency conflicts, and stale-reference preservation. Dynamic, +rich Office, non-launchable, and `AutoFallback` candidates are rejected while +explicit fallback-model policy remains valid. + +Verification: + +- `go test -tags fts5 ./internal/agent/settings/controller/... ./internal/agent/settings/handlers/... ./internal/agent/settings/store/...` + +The command passed. Dedicated API/E2E dependency coverage remains pending. diff --git a/docs/plans/dynamic-agent-routing/task-04-core-route-engine.md b/docs/plans/dynamic-agent-routing/task-04-core-route-engine.md new file mode 100644 index 0000000000..62cdd0b521 --- /dev/null +++ b/docs/plans/dynamic-agent-routing/task-04-core-route-engine.md @@ -0,0 +1,55 @@ +--- +id: "04-core-route-engine" +title: "Core route engine" +status: in_progress +wave: 4 +depends_on: ["03-dynamic-profile-management"] +plan: "plan.md" +spec: "../../specs/agents/dynamic-agent-routing.md" +--- + +# Task 04: Core route engine + +- **Acceptance:** Implement fixed ordering, configured compatibility error actions, + durable route attempts and state, the adapter binding-descriptor contract, + HMAC fingerprint derivation, scope-specific circuits, exclusive half-open + probe leases, and single-owner route generations without plugin dependencies. +- **Files likely touched:** `apps/backend/internal/agent/runtime/dynamic/**`, + `apps/backend/internal/agent/runtime/routingerr/**`, + `apps/backend/internal/agent/runtime/lifecycle/environment_resolution.go`, + `apps/backend/internal/task/repository/sqlite/{base_schema,base_migrations}.go`, + `apps/backend/internal/task/repository/sqlite/*dynamic_route*`, and reusable + logic migrated from `apps/backend/internal/office/routing/`. +- **Dependencies:** Task 03. +- **Parallelism:** sequential. +- **Inputs:** Spec Route selection, Shared health and probing, Data model, State + machine, and circuit scenarios, Task 03, existing Office backoff and + `runtime/routingerr` behavior. +- **Output contract:** Report descriptor producers for every supported concrete + family, canonical fields, installation-key persistence, fallback isolation, + resource scopes, schema changes, files changed, exact commands and results, + blockers, risks, and synchronized task and plan status. +- **Verification:** `cd apps/backend && go test -tags fts5 ./internal/agent/runtime/dynamic/... ./internal/agent/runtime/routingerr/... ./internal/task/repository/sqlite/...` +- **Risks:** Unknown bindings must isolate by profile. Raw secrets, literal + environment values, command prefixes, and account IDs cannot enter keys or + logs. A missing adapter descriptor cannot silently use provider-wide scope. + The generic action map remains a compatibility boundary; the separate + Provider Error Policies package owns its class-policy replacement. + +## Results + +In progress. Implemented fixed-order selection, semantic retry/next actions, +durable route state and attempts, HMAC installation-key fingerprints, +profile-scoped unknown bindings, circuit state and half-open probe leases, and +generation-checked claims. Added restart loading and atomic SQLite claim tests. +The rollout-blocker repair now connects concrete binding descriptors to route +candidates, opens qualifying circuits, releases probe leases on production +failures, and routes expired circuits through exclusive probes. + +Verification: + +- `go test -tags fts5 ./internal/agent/runtime/dynamic ./internal/task/repository/sqlite -run "Test(DynamicRoute|Engine|Circuit|SQLiteRepository)" -count=1` + +The command passed. Family-specific descriptor producers, complete automatic +circuit close observability, and the broader observability migration remain +open. diff --git a/docs/plans/dynamic-agent-routing/task-05-acp-conductor.md b/docs/plans/dynamic-agent-routing/task-05-acp-conductor.md new file mode 100644 index 0000000000..5ba279ef00 --- /dev/null +++ b/docs/plans/dynamic-agent-routing/task-05-acp-conductor.md @@ -0,0 +1,46 @@ +--- +id: "05-acp-conductor" +title: "ACP conductor" +status: in_progress +wave: 5 +depends_on: ["04-core-route-engine"] +plan: "plan.md" +spec: "../../specs/agents/dynamic-agent-routing.md" +--- + +# Task 05: ACP conductor + +- **Acceptance:** Add the transparent execution resolver and dynamic conductor, + reuse ACP resume only for the owning concrete profile, create fresh sessions + across candidates, build bounded continuation, and fence late generations. +- **Files likely touched:** `apps/backend/internal/agent/runtime/{runtime,facade}.go`, + `apps/backend/internal/agent/runtime/dynamic/**`, + `apps/backend/internal/agent/runtime/lifecycle/{session,event_types,events}*.go`. +- **Dependencies:** Task 04. +- **Parallelism:** sequential. +- **Inputs:** Spec One logical chat, Threshold enforcement, Failure modes, and + Persistence guarantees, Task 04 route state and current runtime lifecycle + launch/resume contracts. +- **Output contract:** Report downstream-session ownership, continuation bounds, + fencing behavior, files changed, exact commands and results, blockers, risks, + and synchronized task and plan status. +- **Verification:** `cd apps/backend && go test -tags fts5 ./internal/agent/runtime/...` +- **Risks:** A successor must never overlap an unfenced effectful predecessor or receive another provider's resume token. + +## Results + +In progress. Added the shared profile-execution resolver, dynamic conductor +contracts, route-aware resume lookup, and concrete-profile launch boundaries. +Task launches, prepared launches, resumes, and utility calls no longer pass a +virtual profile ID to lifecycle execution. The rollout-blocker repair adds +explicit output/effect evidence, bounded durable continuation persistence and +delivery, fail-closed mid-turn fallback, and bounded fallback across all +eligible candidates. + +Verification: + +- `go test -tags fts5 ./internal/agent/runtime ./internal/orchestrator ./internal/backendapp -count=1` + +The command passed. The conductor is not yet the sole owner of lifecycle +launch, stop, event subscription, or restart reconciliation, so those broader +acceptance items remain open. diff --git a/docs/plans/dynamic-agent-routing/task-06-logical-session-integration.md b/docs/plans/dynamic-agent-routing/task-06-logical-session-integration.md new file mode 100644 index 0000000000..9115fd19ca --- /dev/null +++ b/docs/plans/dynamic-agent-routing/task-06-logical-session-integration.md @@ -0,0 +1,50 @@ +--- +id: "06-logical-session-integration" +title: "Logical session integration" +status: in_progress +wave: 6 +depends_on: ["05-acp-conductor"] +plan: "plan.md" +spec: "../../specs/agents/dynamic-agent-routing.md" +--- + +# Task 06: Logical session integration + +- **Acceptance:** Route task/workflow launches through the resolver, keep one + logical task session, persist concrete execution and route generation on each + turn, emit generation-fenced structured route/capability events, and add the + `session.route_action` request with expected-generation conflict handling. +- **Files likely touched:** `apps/backend/internal/orchestrator/{session_launch,task_operations,event_handlers_streaming,workflow_session_config}*.go`, + `apps/backend/internal/task/{models,dto,repository/sqlite,service}/**`, + `apps/backend/internal/gateway/websocket/*`. +- **Dependencies:** Task 05. +- **Parallelism:** sequential. +- **Inputs:** Spec Transparent profile execution, Route and capability events, + API surface, and route-action scenarios, Task 05, existing WebSocket session + request and access-control patterns. +- **Output contract:** Report the request and event contracts, authorization and + conflict behavior, files changed, exact commands and results, blockers, + risks, and synchronized task and plan status. +- **Verification:** `cd apps/backend && go test -tags fts5 ./internal/orchestrator/... ./internal/task/... ./internal/gateway/websocket/...` +- **Risks:** Existing concrete sessions and session subscriptions must retain their current behavior. + +## Results + +In progress. Added logical and concrete session fields, route generation and +turn attribution persistence, generation-fenced `session.route_action`, stale +conflict snapshots, recovery-state handling, and resolver-backed task launch +and resume paths. Route actions now also require a settled turn. The +retry-same path preserves the failed candidate and stale route claims fail +closed after restart. The rollout-blocker repair makes the route action own +selection, predecessor shutdown, successor launch, and durable recovery state +as one backend operation, and fails closed when continuation evidence is +ambiguous. + +Verification: + +- `go test -tags fts5 ./internal/orchestrator/... ./internal/task/... ./internal/backendapp/... -count=1` + +The focused backend suite passed, including active-turn rejection and +settled-turn route-action coverage. Full structured route/capability event +coverage, conductor event subscriptions, and dedicated E2E coverage remain +open. diff --git a/docs/plans/dynamic-agent-routing/task-07-utility-profile-integration.md b/docs/plans/dynamic-agent-routing/task-07-utility-profile-integration.md new file mode 100644 index 0000000000..8f0bce3514 --- /dev/null +++ b/docs/plans/dynamic-agent-routing/task-07-utility-profile-integration.md @@ -0,0 +1,44 @@ +--- +id: "07-utility-profile-integration" +title: "Utility profile integration" +status: in_progress +wave: 7 +depends_on: ["06-logical-session-integration"] +plan: "plan.md" +spec: "../../specs/agents/dynamic-agent-routing.md" +--- + +# Task 07: Utility profile integration + +- **Acceptance:** Make dynamic profiles eligible for all utility bindings, route + calls through the shared resolver, persist logical and concrete attribution, + and allow only pre-result classified fallback. +- **Files likely touched:** `apps/backend/internal/utility/{profilebinding,service,handlers,store}/**`, + `apps/backend/internal/agent/hostutility/**`, + `apps/backend/internal/plugins/host_utility*.go`. +- **Dependencies:** Task 06. +- **Parallelism:** parallel-safe with Task 09. Owned backend utility and plugin + files do not overlap Task 09 frontend session and chat files. +- **Inputs:** Spec Use by utility agents and utility scenarios, Task 06 shared + resolver, the existing utility profile binding and call-record contracts. +- **Output contract:** Report utility fallback boundaries and attribution, + files changed, exact commands and results, blockers, risks, and synchronized + task and plan status. +- **Verification:** `cd apps/backend && go test -tags fts5 ./internal/utility/... ./internal/agent/hostutility/... ./internal/plugins/...` +- **Risks:** Ambiguous partial/effectful utility attempts must fail closed rather than combine provider output. + +## Results + +In progress. Utility bindings now retain the logical profile, persist the +concrete execution profile, pass the concrete ID to session and host executors, +expose that attribution in call DTOs, and advance only classified pre-result +failures. Each utility invocation has an isolated transient route identity, and +partial responses are rejected before fallback. Ambiguous or post-start +runtime failures remain fail-closed. + +Verification: + +- `go test -tags fts5 ./internal/utility/... ./internal/agent/hostutility/... -count=1` + +The utility suite passed, including a concrete-profile fallback test. Plugin +host utility routing and caller-selection E2E coverage remain open. diff --git a/docs/plans/dynamic-agent-routing/task-08-dynamic-profile-settings.md b/docs/plans/dynamic-agent-routing/task-08-dynamic-profile-settings.md new file mode 100644 index 0000000000..d6ad6813dc --- /dev/null +++ b/docs/plans/dynamic-agent-routing/task-08-dynamic-profile-settings.md @@ -0,0 +1,48 @@ +--- +id: "08-dynamic-profile-settings" +title: "Dynamic profile settings" +status: completed +wave: 4 +depends_on: ["03-dynamic-profile-management"] +plan: "plan.md" +spec: "../../specs/agents/dynamic-agent-routing.md" +--- + +# Task 08: Dynamic profile settings + +- **Acceptance:** Add typed API/state support and desktop/mobile dynamic profile + creation, candidate ordering, validation, dependency dialogs, and core error + actions behind the feature flag. +- **Files likely touched:** `apps/web/lib/types/agent-profile.ts`, + `apps/web/lib/api/domains/agent-profile-normalize.ts`, + `apps/web/components/settings/agents/agent-profiles-section.tsx`, + `apps/web/components/settings/agent-profile-page*.tsx`, locale catalogs. +- **Dependencies:** Task 03. +- **Parallelism:** parallel-safe with Task 04. This task owns only web profile + settings, state, and locale files after Task 03 fixes the API contract. +- **Inputs:** Spec Settings interaction and mobile parity, Task 03 API contract, + `/mobile-parity`, `agent-profiles-section.tsx`, `MobilePickerSheet`, and the + Kandev mobile UI language reference. +- **Output contract:** Report desktop and phone entry points, surface choice, + scroll owner, touch targets, files changed, exact commands and results, + blockers, risks, and synchronized task and plan status. +- **Verification:** `cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- components/settings lib/api/domains/agent-profile-normalize.test.ts && cd web && pnpm run typecheck && pnpm run i18n:check && pnpm run i18n:ratchet` +- **Risks:** Phone uses direct navigation, one scroll owner, 44px reorder actions, and no drag-only behavior. + +## Results + +Completed. Added typed dynamic profile API/state normalization, desktop +settings editing, direct mobile profile navigation, ordered candidate editing, +validation, localized copy, and recovery-state support. + +Verification: + +- `pnpm --filter @kandev/web test -- --run lib/api/domains/agent-profile-normalize.test.ts lib/state/slices/features/features-contract.test.ts components/settings/agents/agent-profiles-section.test.tsx lib/ws/client.test.ts` +- `pnpm --filter @kandev/web run typecheck` +- `pnpm --filter @kandev/web run i18n:check` +- `pnpm --filter @kandev/web run i18n:ratchet` + +All commands passed. Dedicated settings Playwright coverage remains pending. +The separate Provider Error Policies package owns one-draft creation and the +transient/hard retry, reset-wait, skip, and stop editor that supersedes the +generic action control delivered here. diff --git a/docs/plans/dynamic-agent-routing/task-09-routed-chat-presentation.md b/docs/plans/dynamic-agent-routing/task-09-routed-chat-presentation.md new file mode 100644 index 0000000000..7724d8048c --- /dev/null +++ b/docs/plans/dynamic-agent-routing/task-09-routed-chat-presentation.md @@ -0,0 +1,48 @@ +--- +id: "09-routed-chat-presentation" +title: "Routed chat presentation" +status: in_progress +wave: 7 +depends_on: ["06-logical-session-integration", "08-dynamic-profile-settings"] +plan: "plan.md" +spec: "../../specs/agents/dynamic-agent-routing.md" +--- + +# Task 09: Routed chat presentation + +- **Acceptance:** Apply route/capability events by generation, preserve logical + tab and author identity, localize reason enums, render immutable concrete + badges, replace controls without remounting the composer, and render waiting + or action-required recovery actions with expected generations. +- **Files likely touched:** `apps/web/lib/state/slices/session/**`, + `apps/web/hooks/domains/session/**`, `apps/web/components/task/chat/**`, + `apps/web/components/task/dockview-{shared,panel-content}.tsx`, locale catalogs. +- **Dependencies:** Tasks 06 and 08. +- **Parallelism:** parallel-safe with Task 07. This task owns only frontend + session and chat files after Task 06 fixes the event and action contracts. +- **Inputs:** Spec Route and capability events, Settings interaction and mobile + parity, and API surface, Tasks 06 and 08, existing session action hooks and + mobile task chat composition. +- **Output contract:** Report generation handling, localized route reasons, + mobile recovery controls, files changed, exact commands and results, + blockers, risks, and synchronized task and plan status. +- **Verification:** `cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- lib/state/slices/session components/task/chat hooks/domains/session && cd web && pnpm run typecheck && pnpm run i18n:check && pnpm run i18n:ratchet` +- **Risks:** Late frames cannot overwrite new provider controls or historical attribution. + +## Results + +In progress. Added generation-aware session state updates, route fields in +session events, localized waiting/action-required recovery UI, and desktop and +mobile recovery controls that keep the composer mounted. The rollout-blocker +repair adds one-request route recovery, restores the Dynamic profile list and +dedicated profile navigation, and filters disabled dynamic profiles from new +selection surfaces. + +Verification: + +- `pnpm --filter @kandev/web test -- --run lib/state/slices/session components/task/chat hooks/domains/session` +- `pnpm --filter @kandev/web run typecheck` +- `pnpm --filter @kandev/web run i18n:check` + +The focused frontend checks passed. Immutable per-turn provider badges, +capability replacement, and routed-session Playwright coverage remain open. diff --git a/docs/plans/dynamic-agent-routing/task-10-office-routing-handoff.md b/docs/plans/dynamic-agent-routing/task-10-office-routing-handoff.md new file mode 100644 index 0000000000..2d8a4999e3 --- /dev/null +++ b/docs/plans/dynamic-agent-routing/task-10-office-routing-handoff.md @@ -0,0 +1,42 @@ +--- +id: "10-office-routing-handoff" +title: "Office routing handoff" +status: pending +wave: 8 +depends_on: ["06-logical-session-integration", "08-dynamic-profile-settings"] +plan: "plan.md" +spec: "../../specs/agents/dynamic-agent-routing.md" +--- + +# Task 10: Office routing handoff + +- **Acceptance:** Persist Office `execution_agent_profile_id`, use the shared + resolver, remove legacy routing endpoints, UI, seeding, runtime ownership, + `StartTaskWithRoute`, and the full `RouteOverride` overlay chain. Prove all + three legacy tables and rows survive upgrade unchanged and unread. +- **Files likely touched:** `apps/backend/internal/office/{scheduler,onboarding,dashboard,service,routing,repository/sqlite}/**`, + `apps/backend/internal/orchestrator/task_operations.go`, + `apps/backend/internal/orchestrator/executor/{executor,executor_execute}.go`, + `apps/backend/internal/backendapp/{main,adapters}.go`, + `apps/backend/internal/agent/runtime/lifecycle/{manager_launch,types}.go`, + `apps/backend/internal/agent/registry/canonical_routing_providers.go`, + `apps/web/app/office/**`, `apps/web/hooks/domains/office/**`, + `apps/web/lib/api/domains/office-routing-api.ts`, + `apps/web/e2e/tests/office/{office-routing-*,mobile-provider-routing-profiles}.spec.ts`, + `apps/web/e2e/helpers/office-routing.ts`, and `AGENTS.md`. +- **Dependencies:** Tasks 06 and 08. +- **Parallelism:** sequential. +- **Inputs:** Spec Use in Office, Delivery and rollout, and legacy-row scenario, + ADR Legacy Office routing data, Tasks 06 and 08, the existing Office routing + package and route-override launch chain. +- **Output contract:** Report removed runtime owners and tests, retained schema + evidence, flag-matrix behavior, files changed, exact commands and results, + blockers, risks, and synchronized task and plan status. +- **Verification:** `cd apps/backend && go test -tags fts5 ./internal/office/... ./internal/orchestrator/... ./internal/backendapp/... ./internal/agent/runtime/lifecycle/... && cd ../../apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- app/office hooks/domains/office lib/state/slices/office` +- **Risks:** Do not drop, rewrite, or display legacy Office routing rows. + Unrelated Office data and disabled-mode behavior must remain valid. + +## Results + +Not started in this implementation slice. The existing Office routing chain, +legacy runtime ownership, and retained-row verification remain unchanged. diff --git a/docs/plans/dynamic-agent-routing/task-11-core-routing-observability.md b/docs/plans/dynamic-agent-routing/task-11-core-routing-observability.md new file mode 100644 index 0000000000..0dcdfaf7fb --- /dev/null +++ b/docs/plans/dynamic-agent-routing/task-11-core-routing-observability.md @@ -0,0 +1,36 @@ +--- +id: "11-core-routing-observability" +title: "Core routing observability" +status: pending +wave: 9 +depends_on: ["04-core-route-engine", "06-logical-session-integration", "10-office-routing-handoff"] +plan: "plan.md" +spec: "../../specs/agents/dynamic-agent-routing.md" +--- + +# Task 11: Core routing observability + +- **Acceptance:** Publish structured logs and expvars for decisions, retries, + fallback, waiting, circuits, probes, continuation, restart, stale events, and + utility outcomes. Preserve `routing_route_attempts_total` and + `routing_fallback_total`. Replace provider degraded/recovered and parked-run + metrics with resource-circuit opened/closed and waiting-session metrics. +- **Files likely touched:** `apps/backend/internal/agent/runtime/dynamic/*metrics*.go`, + `apps/backend/internal/office/scheduler/{routing_metrics,metrics_vars}.go`, + `AGENTS.md`, `apps/backend/AGENTS.md`. +- **Dependencies:** Tasks 04, 06, and 10. +- **Parallelism:** sequential. +- **Inputs:** Spec Observability, plan Observability migration, Tasks 04, 06, + and 10, current Office metric variables, structured logs, and root + observability guidance. +- **Output contract:** Report every preserved, retired, and replacement metric + and log event, safe labels, files changed, exact commands and results, + blockers, risks, and synchronized task and plan status. +- **Verification:** `cd apps/backend && go test -tags fts5 ./internal/agent/runtime/dynamic/... ./internal/office/scheduler/...` +- **Risks:** Labels must exclude prompts, credentials, raw account IDs, and unbounded profile names. + +## Results + +Not started in this implementation slice. Dynamic routing metrics and the +Office observability migration remain deferred to the later implementation +wave. diff --git a/docs/plans/dynamic-agent-routing/task-12-profile-settings-e2e.md b/docs/plans/dynamic-agent-routing/task-12-profile-settings-e2e.md new file mode 100644 index 0000000000..ae9e0237d5 --- /dev/null +++ b/docs/plans/dynamic-agent-routing/task-12-profile-settings-e2e.md @@ -0,0 +1,38 @@ +--- +id: "12-profile-settings-e2e" +title: "Profile settings E2E" +status: pending +wave: 5 +depends_on: ["08-dynamic-profile-settings"] +plan: "plan.md" +spec: "../../specs/agents/dynamic-agent-routing.md" +--- + +# Task 12: Profile settings E2E + +- **Acceptance:** Add desktop and Pixel 5 flows that create, edit, reorder, and + reload a dynamic profile. Prove direct phone navigation, the inset candidate + picker, 44px move actions, one scroll owner, safe-area clearance, and no + document horizontal overflow. +- **Files likely touched:** + `apps/web/e2e/tests/settings/dynamic-agent-profile.spec.ts`, + `apps/web/e2e/tests/settings/mobile-dynamic-agent-profile.spec.ts`, and focused + settings page objects or helpers. +- **Dependencies:** Task 08. +- **Parallelism:** parallel-safe with Task 05. This task owns settings E2E files + and does not touch backend runtime files. +- **Inputs:** Spec Settings interaction and mobile parity, Task 08, `/e2e`, + `/mobile-parity`, existing agent-profile and mobile settings E2E patterns. +- **Output contract:** Report the RED and GREEN runs, discovered test counts, + mobile geometry evidence, files changed, exact commands and results, cleanup, + blockers, risks, and synchronized task and plan status. +- **Verification:** `cd apps && pnpm install --frozen-lockfile && cd web && pnpm e2e:run --project chromium tests/settings/dynamic-agent-profile.spec.ts && pnpm e2e:run --project mobile-chrome tests/settings/mobile-dynamic-agent-profile.spec.ts` +- **Risks:** Use causal waits and restore shared profile state. A visible row is + not sufficient proof of touch behavior or persistence. Per-class policy E2E + belongs to the Provider Error Policies package; this task retains baseline + dynamic profile creation and ordering coverage. + +## Results + +Not started in this implementation slice. Desktop and Pixel 5 Playwright +coverage for dynamic profile editing remains pending. diff --git a/docs/plans/dynamic-agent-routing/task-13-routed-session-e2e.md b/docs/plans/dynamic-agent-routing/task-13-routed-session-e2e.md new file mode 100644 index 0000000000..80d5cbd004 --- /dev/null +++ b/docs/plans/dynamic-agent-routing/task-13-routed-session-e2e.md @@ -0,0 +1,43 @@ +--- +id: "13-routed-session-e2e" +title: "Routed session E2E" +status: pending +wave: 10 +depends_on: ["09-routed-chat-presentation", "11-core-routing-observability"] +plan: "plan.md" +spec: "../../specs/agents/dynamic-agent-routing.md" +--- + +# Task 13: Routed session E2E + +- **Acceptance:** Add isolated desktop and mobile Playwright projects for + multi-provider routing. Prove one-tab fallback, immutable badges, localized + route rows, capability replacement, waiting actions, stale-generation + rejection, restart recovery, and retained composer state. Use explicit + project matchers for every multi-provider spec in this package and exclude those + paths from the default Chromium and mobile projects. The desktop project owns + the task, rollout, workflow, utility, and Office files named in the plan. The + mobile project owns the two phone files named there. +- **Files likely touched:** `apps/web/e2e/playwright.config.ts`, + `apps/web/e2e/tests/task/dynamic-agent-routing.spec.ts`, + `apps/web/e2e/tests/task/mobile-dynamic-agent-routing.spec.ts`, and focused + dynamic-routing fixtures, helpers, or page-object methods. +- **Dependencies:** Tasks 09 and 11. +- **Parallelism:** parallel-safe with Task 15. This task owns E2E configuration + and routing specs. Task 15 owns only public documentation. +- **Inputs:** Spec One logical chat, Route and capability events, API surface, + Persistence guarantees, and route-action scenarios, Tasks 09 and 11, `/e2e`, + `/mobile-parity`, current routing project isolation rules. +- **Output contract:** Report the dedicated project matchers, baseline reset, + RED and GREEN runs, discovered test counts, files changed, exact commands and + results, cleanup, blockers, risks, and synchronized task and plan status. +- **Verification:** `cd apps && pnpm install --frozen-lockfile && cd web && pnpm e2e:run --project dynamic-routing tests/task/dynamic-agent-routing.spec.ts && pnpm e2e:run --project dynamic-routing-mobile tests/task/mobile-dynamic-agent-routing.spec.ts` +- **Risks:** Each restart must restore baseline environment state. The desktop + project must not match the mobile file, and neither spec can run in Chromium's + default worker. Project matchers must also reserve the caller and Office specs + owned by Tasks 14 and 16 without matching profile-settings specs. + +## Results + +Not started in this implementation slice. The isolated routing Playwright +projects and restart/recovery scenarios remain pending. diff --git a/docs/plans/dynamic-agent-routing/task-14-caller-selection-e2e.md b/docs/plans/dynamic-agent-routing/task-14-caller-selection-e2e.md new file mode 100644 index 0000000000..d49bf4b466 --- /dev/null +++ b/docs/plans/dynamic-agent-routing/task-14-caller-selection-e2e.md @@ -0,0 +1,34 @@ +--- +id: "14-caller-selection-e2e" +title: "Caller selection E2E" +status: pending +wave: 11 +depends_on: ["07-utility-profile-integration", "13-routed-session-e2e"] +plan: "plan.md" +spec: "../../specs/agents/dynamic-agent-routing.md" +--- + +# Task 14: Caller selection E2E + +- **Acceptance:** Prove workflow and utility selectors pass one logical dynamic + profile ID and receive routed concrete attribution. Prove a utility fallback + returns one result without creating a task tab. +- **Files likely touched:** + `apps/web/e2e/tests/workflow/dynamic-agent-profile.spec.ts`, + `apps/web/e2e/tests/settings/dynamic-utility-profile.spec.ts`, and focused E2E + helpers already matched by the dedicated routing project. +- **Dependencies:** Tasks 07 and 13. +- **Parallelism:** sequential. +- **Inputs:** Spec Use in Kanban and workflows and Use by utility agents, Tasks + 07 and 13, `/e2e`, and current workflow and utility fixtures. +- **Output contract:** Report caller attribution, utility tab-count evidence, + RED and GREEN runs, discovered test counts, files changed, exact commands and + results, cleanup, blockers, risks, and synchronized task and plan status. +- **Verification:** `cd apps && pnpm install --frozen-lockfile && cd web && pnpm e2e:run --project dynamic-routing tests/workflow/dynamic-agent-profile.spec.ts tests/settings/dynamic-utility-profile.spec.ts` +- **Risks:** Poll exact session state before lifecycle assertions. Use API state + only as supporting evidence for a visible UI outcome. + +## Results + +Not started in this implementation slice. Workflow and utility selector E2E +coverage remains pending. diff --git a/docs/plans/dynamic-agent-routing/task-15-core-routing-public-docs.md b/docs/plans/dynamic-agent-routing/task-15-core-routing-public-docs.md new file mode 100644 index 0000000000..3602fb50b5 --- /dev/null +++ b/docs/plans/dynamic-agent-routing/task-15-core-routing-public-docs.md @@ -0,0 +1,48 @@ +--- +id: "15-core-routing-public-docs" +title: "Core routing public docs" +status: in_progress +wave: 10 +depends_on: ["08-dynamic-profile-settings", "09-routed-chat-presentation", "10-office-routing-handoff"] +plan: "plan.md" +spec: "../../specs/agents/dynamic-agent-routing.md" +--- + +# Task 15: Core routing public docs + +- **Acceptance:** Document dynamic profiles, fixed-order fallback, waiting and + recovery actions, workflow and Office selection, the experimental runtime + flag, all-off defaults, and restart behavior. Do not document telemetry rules + as part of this feature. +- **Files likely touched:** `docs/public/agents-and-profiles.md`, + `docs/public/configuration.md`, `docs/public/feature-status.md`, and + `docs/public/tasks-and-workflows.md`. +- **Dependencies:** Tasks 08, 09, and 10. +- **Parallelism:** parallel-safe with Task 13. This task owns only + `docs/public/**` files. +- **Inputs:** Spec Profile configuration, Settings interaction, Use in Kanban + and workflows, Use in Office, and Delivery and rollout, Tasks 08 through 10, + `/docs-maintainer`, runtime feature-flag public documentation patterns. +- **Output contract:** Report each public page and its Diataxis type, files + changed, exact commands and results, blockers, risks, and synchronized task + and plan status. +- **Verification:** `node --test scripts/validate-public-docs.test.mjs && node scripts/validate-public-docs.mjs && rg -n "dynamicAgentRouting|Dynamic profile|dynamic profile" docs/public` +- **Risks:** Public documentation must not present deferred telemetry behavior + as part of dynamic agent routing. + +## Results + +In progress. Updated the public agents, configuration, feature-status, and task +workflow guides with the experimental flag, all-off production default, +restart requirement, fixed-order behavior, logical-session boundary, and +settled-turn recovery actions. Office handoff wording is intentionally not +claimed because Task 10 is still pending. + +Verification: + +- `node --test scripts/validate-public-docs.test.mjs` +- `node scripts/validate-public-docs.mjs` +- `rg -n "dynamicAgentRouting|Dynamic profile|dynamic profile" docs/public` + +All three checks passed. Office handoff wording is intentionally not claimed +because Task 10 is still pending. diff --git a/docs/plans/dynamic-agent-routing/task-16-office-rollout-e2e.md b/docs/plans/dynamic-agent-routing/task-16-office-rollout-e2e.md new file mode 100644 index 0000000000..2767a30799 --- /dev/null +++ b/docs/plans/dynamic-agent-routing/task-16-office-rollout-e2e.md @@ -0,0 +1,40 @@ +--- +id: "16-office-rollout-e2e" +title: "Office rollout E2E" +status: pending +wave: 11 +depends_on: ["10-office-routing-handoff", "13-routed-session-e2e"] +plan: "plan.md" +spec: "../../specs/agents/dynamic-agent-routing.md" +--- + +# Task 16: Office rollout E2E + +- **Acceptance:** Prove desktop and phone Office identities can select concrete + or dynamic execution profiles. Prove legacy routing rows remain unread. Cover + `office=on` with dynamic routing off, persisted dynamic restart, re-enable + reconciliation, and concrete Office execution. +- **Files likely touched:** + `apps/web/e2e/tests/office/dynamic-agent-execution-profile.spec.ts`, + `apps/web/e2e/tests/office/mobile-dynamic-agent-execution-profile.spec.ts`, + `apps/web/e2e/tests/task/dynamic-agent-routing-rollout.spec.ts`, and focused + Office or runtime-flag E2E helpers matched by the dedicated routing projects. +- **Dependencies:** Tasks 10 and 13. +- **Parallelism:** sequential. Office and rollout scenarios share restart and + feature-flag fixture state. +- **Inputs:** Spec Use in Office, Delivery and rollout, Persistence guarantees, + and legacy-row and flag scenarios, Tasks 10 and 13, `/e2e`, `/mobile-parity`, + and current Office fixtures. +- **Output contract:** Report the flag matrix, mobile selector outcome, + legacy-row evidence, RED and GREEN runs, discovered test counts, files + changed, exact commands and results, cleanup, blockers, risks, and + synchronized task and plan status. +- **Verification:** `cd apps && pnpm install --frozen-lockfile && cd web && pnpm e2e:run --project dynamic-routing tests/office/dynamic-agent-execution-profile.spec.ts tests/task/dynamic-agent-routing-rollout.spec.ts && pnpm e2e:run --project dynamic-routing-mobile tests/office/mobile-dynamic-agent-execution-profile.spec.ts` +- **Risks:** Every restart must restore baseline environment state. Poll exact + session state before lifecycle assertions. Re-enabling the flag must reconcile + durable state without a duplicate launch. + +## Results + +Not started in this implementation slice. Office selector, legacy-row, flag +matrix, and mobile rollout E2E coverage remains pending with Task 10. diff --git a/docs/plans/provider-error-policies/plan.md b/docs/plans/provider-error-policies/plan.md new file mode 100644 index 0000000000..ecc8ea807d --- /dev/null +++ b/docs/plans/provider-error-policies/plan.md @@ -0,0 +1,245 @@ +--- +spec: docs/specs/platform/provider-error-recovery.md +created: 2026-08-17 +status: done +--- + +# Implementation Plan: Provider Error Policies + +## Overview + +Replace dynamic candidates' generic provider-error action map with shared, +versioned transient and hard policies. Build a deterministic provider-error +catalogue, persist bounded reset waits and exponential retries, and apply the +same policy in Kanban, utility calls, workflows, and Office without weakening +effect-safety or route-generation fencing. + +This package also removes Add profile from the one-profile creation route and +adds complete desktop and mobile policy controls to each candidate. Model-based +error classification, cost routing, and telemetry routing are deferred. + +## Product and architecture contract + +- `routingerr` owns deterministic evidence normalization, semantic codes, + classes, timing hints, catalogue versions, and fixture-driven provider + signatures. +- A shared runtime policy package owns validation and evaluation of reset wait, + exponential retry, skip, and stop. Workspace modes do not copy its tables. +- Unknown, low-confidence, stale, conflicting, or effect-unsafe failures stop + automatic recovery. +- Each class policy has optional reset waiting, optional retry, and an explicit + exhausted outcome. This prevents retry exhaustion from having an implicit + behavior. +- Legacy dynamic rules normalize to a versioned policy document. The backend + returns only the normalized form. +- Retry and reset deadlines are backend-owned, durable, restart-safe, and + generation-fenced. +- The dynamic new-profile route owns one draft and has no Add profile action. +- Desktop and phone expose the same values and validation. Phone uses one + column, one scroll owner, 44px controls, and no horizontal overflow. + +## Backend + +### Classification and catalogue + +- Add a `Class` and catalogue version to normalized provider errors. +- Exhaustively map recoverable semantic codes to `transient` or `hard`. + Non-provider and unknown evidence remain outside configurable policy. +- Extend structured and fixture-driven rules for capacity, network, timeout, + outage, throttling, subscription, quota/reset, credits, auth, credentials, + model, and provider-configuration failures across supported adapters. +- Validate and sanitize `retry_after` and `reset_at`; timing extraction cannot + trust arbitrary model output. +- Remove classifier-owned recovery booleans after all consumers move to the + shared policy contract. + +### Policy document and compatibility + +- Replace candidate `rules: Record` with a versioned typed policy + containing complete transient and hard sections. +- Validate finite bounds, supported outcomes, complete class coverage, and + version compatibility in profile CRUD. +- Normalize legacy `try_next`, `stop`, `retry_same`, and per-code maps without + changing stored candidate order or enabled state. +- Return field-addressable API errors and normalized policy on create/update. + +### Durable evaluator and scheduler + +- Evaluate the effect-safety gate before policy. +- Prefer a trusted reset deadline only when it is in the future and within the + configured maximum. Otherwise evaluate the next exponential retry or final + outcome. +- Persist policy snapshot, class, semantic code, catalogue version, retry + ordinal, deadline, and pending exhausted outcome before scheduling. +- Add `retry_wait`, `waiting_for_reset`, and `retrying` transitions to the + generation-fenced route state machine. +- Reconcile scheduled work after restart only when dispatch is proven absent. + Use deterministic clocks and `testing/synctest`; do not use sleeps. +- Coordinate shared circuits so one probe or retry owner prevents a provider + stampede. + +### Caller convergence + +- Route dynamic launch, settled-turn failures, manual actions, and continuation + through the evaluator. +- Give utility calls unique invocation identities and reject recovery after any + partial result or ambiguous effect. +- Replace Kanban and Office provider-code allow-lists with the shared class and + timing contract. Concrete-profile defaults can differ, but classification + cannot. +- Keep Office scheduler wake reasons and legacy routing rows from overriding + the shared class contract. The current Office execution-profile catalog + excludes rich dynamic profiles without a model; concrete Office routes use + the shared class metadata while task and Kanban dynamic routes use the full + per-candidate policy document. + +## Frontend + +### Dynamic candidate editor + +- Add typed transient and hard policy state to API normalization and profile + drafts. +- Render separate, visibly explained class sections for every candidate. +- Let users enable retry, edit max retries and initial interval, enable trusted + reset waiting, edit max wait, and choose Skip candidate or Stop after + exhaustion. +- Show the derived exponential schedule and inline validation. Tooltips may add + detail but cannot contain the only behavior explanation. +- Remove Add profile when the route is creating a new dynamic profile. Keep + profile creation on the Dynamic agents list. +- Split the current editor into focused components and hooks so component and + file limits remain within web guidance. + +### Recovery presentation + +- Show class, safe cause, retry ordinal, deadline, and exhausted outcome in + task and Office route state. +- Replace generic Retry/Try next actions with generation-fenced Retry now, Skip + now, Cancel wait, and Stop where valid. +- Keep the dynamic logical identity, continuation, immutable turn attribution, + and capability replacement behavior unchanged. + +## Data migration + +- Reuse the existing candidate JSON column for a new document version unless + repository implementation proves a column rename is required. Do not rewrite + candidate identity or order. +- Normalize legacy documents transactionally on first profile write or through + an idempotent migration. Invalid conflicts become actionable configuration + errors and never authorize work. +- Add route-state columns or a bounded policy-state JSON document for durable + schedules. Backfill existing rows to no pending schedule. +- Historical attempts keep their original code and gain class/catalogue fields + only when known. Do not relabel old attempts using a newer catalogue. + +## Public documentation + +- Update agent/profile documentation with both error classes, retry schedule, + reset-wait maximum, skip/stop outcomes, safe defaults, and unknown-error + behavior. +- Explain that a dynamic profile behaves the same in Kanban and Office and that + provider-message coverage grows through deterministic catalogue updates. +- Keep future model-based classification and telemetry routing clearly marked + as unavailable. + +## Test strategy + +| What | File | How | +| --- | --- | --- | +| Every semantic code has one class or is explicitly outside provider policy | `apps/backend/internal/agent/runtime/routingerr/classify_test.go` and new catalogue tests | Table-driven exhaustive enum test | +| Provider signatures, timing hints, false positives, stale evidence, and redaction | `apps/backend/internal/agent/runtime/routingerr/*_test.go` | Sanitized fixture tables per adapter/provider family | +| Legacy actions normalize to complete class policies | `apps/backend/internal/agent/settings/controller/dynamic_profile_test.go` | Table-driven unit tests including conflicting per-code rules | +| Profile create/update persists and returns canonical policy | `apps/backend/internal/agent/settings/store/sqlite_dynamic_profile_test.go` and handler tests | Handler to controller to real SQLite integration test | +| `NextRetryDelay` doubles and caps safely | New shared policy `*_test.go` | Deterministic boundary table for every numeric limit | +| `Evaluate` orders effect safety, one reset wait, retry, skip, and stop | New shared policy `*_test.go` | Table-driven unit tests with injected clock | +| Deadlines and route generations survive restart and races | `apps/backend/internal/task/repository/sqlite/dynamic_route_test.go` and `apps/backend/internal/agent/runtime/dynamic/*_test.go` | Real SQLite plus `testing/synctest` integration tests | +| Kanban concrete defaults consume shared classes | `apps/backend/internal/orchestrator/*transient*_test.go` | Orchestrator integration tests with classified events | +| Utility recovery rejects partial results and isolates invocation IDs | `apps/backend/internal/utility/**/*_test.go` | Service/handler integration tests | +| Office consumes selected dynamic policy without a second allow-list | `apps/backend/internal/office/**/*routing*_test.go` | Scheduler/runtime integration tests | +| Draft normalization, limits, one-profile creation, and policy controls | `apps/web/lib/api/domains/agent-profile-normalize.test.ts` and `apps/web/components/settings/*dynamic*test.tsx` | Vitest component and state tests | +| Route snapshots and actions render current policy state | Task chat and Office recovery component tests | Vitest event/state/component integration tests | + +## E2E tests + +- **Scenario:** Creating a dynamic profile shows one draft, no Add profile + action, and complete transient/hard controls; saving and reloading preserves + them. **File:** + `apps/web/e2e/tests/settings/dynamic-agent-profile-card.spec.ts`. + **Verify:** field values, derived schedule, saved API state, and direct edit + route. +- **Scenario:** The same editor works on Pixel 5. **File:** + `apps/web/e2e/tests/settings/mobile-dynamic-agent-profile-card.spec.ts`. + **Verify:** one column, one scroll owner, 44px controls, picker behavior, and + zero document horizontal overflow. +- Runtime retry, reset-wait, skip, stop, restart reconciliation, and + generation fencing are covered by the deterministic backend suites. This + checkout has no `dynamic-routing` or Office dynamic-profile Playwright + fixtures, so the browser release gate covers the shipped settings surface + on Chromium and Pixel 5; no unavailable fixture is claimed as passing. + +## Verification gate + +- Focused Go tests pass under the `fts5` tag for classifier, policy, dynamic + routing, orchestrator, utility, Office, and SQLite packages. +- Backend lint passes with no new complexity or timer-test violations. +- Focused web tests, typecheck, lint, `i18n:check`, and `i18n:ratchet` pass. +- Desktop and mobile Playwright policy flows pass without retries or flakes. +- Public documentation validation and `git diff --check` pass. + +## Implementation waves + +Wave 1: + +- [x] [Task 01: Shared error catalogue](task-01-shared-error-catalogue.md) + +Wave 2: + +- [x] [Task 02: Versioned policy document](task-02-versioned-policy-document.md) + +Wave 3: + +- [x] [Task 03: Durable policy evaluator](task-03-durable-policy-evaluator.md) + +Wave 4 (parallel candidates after Task 03): + +- [x] [Task 04: Dynamic conductor policy integration](task-04-dynamic-conductor-policy-integration.md) +- [x] [Task 08: Dynamic policy settings UI](task-08-dynamic-policy-settings-ui.md) + +Wave 5: + +- [x] [Task 05: Kanban recovery convergence](task-05-kanban-recovery-convergence.md) + +Wave 6: + +- [x] [Task 06: Utility policy integration](task-06-utility-policy-integration.md) + +Wave 7: + +- [x] [Task 07: Office policy convergence](task-07-office-policy-convergence.md) + +Wave 8: + +- [x] [Task 09: Recovery presentation](task-09-recovery-presentation.md) + +Wave 9: + +- [x] [Task 10: Policy end-to-end coverage](task-10-policy-e2e.md) + +Wave 10: + +- [x] [Task 11: Documentation release gate](task-11-documentation-release-gate.md) + +Sequential execution remains the default. Task 04 and Task 08 are the only +declared parallel pair because they own disjoint backend runtime and frontend +settings files after the policy API is fixed. + +## Verification Results + +Implementation complete. The package now has a versioned provider-error +catalogue, class policies, durable generation-fenced recovery, shared +Kanban/utility/Office classification, dynamic settings controls, task recovery +presentation, and public documentation. Runtime behavior is covered by the +focused backend suites; Chromium and Pixel 5 settings flows pass three repeats +each without flakes. The planned dynamic-routing and Office dynamic-profile +Playwright fixtures are not present in this checkout and are recorded as a +coverage boundary rather than reported as passing. diff --git a/docs/plans/provider-error-policies/task-01-shared-error-catalogue.md b/docs/plans/provider-error-policies/task-01-shared-error-catalogue.md new file mode 100644 index 0000000000..ba250a69d8 --- /dev/null +++ b/docs/plans/provider-error-policies/task-01-shared-error-catalogue.md @@ -0,0 +1,49 @@ +--- +id: "01-shared-error-catalogue" +title: "Shared error catalogue" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/platform/provider-error-recovery.md" +--- + +# Task 01: Shared error catalogue + +- **Acceptance:** Add provider-neutral transient/hard classification, + catalogue version and provenance, trusted timing metadata, exhaustive code + coverage, and fixture-driven signatures for supported agent families. + Unknown, non-provider, stale, and low-confidence evidence remains + unclassified and cannot authorize recovery. +- **Files likely touched:** + `apps/backend/internal/agent/runtime/routingerr/{routingerr,rules,provider_neutral_rules,runtime_rules,policy}.go`, + adapter evidence producers under `apps/backend/internal/agent/agents/**` and + `apps/backend/internal/agentctl/**`, and `routingerr/**/*_test.go`. +- **Dependencies:** none. +- **Parallelism:** sequential foundation. +- **Inputs:** Provider Error Recovery Evidence, Error classes, and Failure modes; + ADR-2026-08-17; current routingerr fixtures and adapter evidence contracts. +- **Output contract:** Report the code-to-class table, catalogue versioning, + timing trust rules, provider fixtures added, redaction evidence, files + changed, exact commands/results, risks, and synchronized task/plan status. +- **Verification:** `cd apps/backend && go test -tags fts5 ./internal/agent/runtime/routingerr/... ./internal/agent/agents/... ./internal/agentctl/...` +- **Risks:** Broad regexes can misclassify model-authored text. Structured and + correlated evidence must outrank bounded text signatures. Sensitive values + cannot enter fixtures, state, logs, or metrics. + +## Results + +Completed. Added the provider-neutral transient, hard, and unclassified error +classes, persisted `provider-errors.v1` catalogue provenance, and covered the +initial semantic code catalogue with table-driven tests. Existing provider +fixtures and sanitized diagnostics remain unchanged. + +Verification: + +- `cd apps/backend && go test ./internal/agent/runtime/routingerr -run TestClassifyAssignsSharedProviderErrorClasses -count=1` passed: 6 tests. +- `cd apps/backend && go test ./internal/agent/runtime/routingerr -run 'Test(ClassForCode|ClassifyAssignsSharedProviderErrorClasses|ClassifyPersistsCatalogueVersion)' -count=1` passed: 8 tests. +- `cd apps/backend && go test -tags fts5 ./internal/agent/runtime/routingerr/... ./internal/agent/agents/... ./internal/agentctl/...` passed: 2,597 tests in 18 packages. + +Security/trust boundary: class assignments are deterministic and unknown codes +remain unclassified. No raw provider credentials or unbounded error text were +added. diff --git a/docs/plans/provider-error-policies/task-02-versioned-policy-document.md b/docs/plans/provider-error-policies/task-02-versioned-policy-document.md new file mode 100644 index 0000000000..6ed9b87e2b --- /dev/null +++ b/docs/plans/provider-error-policies/task-02-versioned-policy-document.md @@ -0,0 +1,57 @@ +--- +id: "02-versioned-policy-document" +title: "Versioned policy document" +status: done +wave: 2 +depends_on: ["01-shared-error-catalogue"] +plan: "plan.md" +spec: "../../specs/platform/provider-error-recovery.md" +--- + +# Task 02: Versioned policy document + +- **Acceptance:** Replace generic dynamic candidate rule maps with typed, + versioned transient and hard policies; validate retry/wait bounds and + exhausted outcomes; normalize legacy maps; and return field-addressable + errors plus the canonical document from profile CRUD. +- **Files likely touched:** + `apps/backend/internal/agent/settings/{dto,models,controller,store}/**`, + `apps/backend/internal/agent/runtime/dynamic/types.go`, profile config + resolution, API handlers, SQLite migrations/tests, and + `apps/web/lib/{types,api/domains}/**`. +- **Dependencies:** Task 01. +- **Parallelism:** sequential because it fixes the API consumed by runtime and + UI work. +- **Inputs:** Provider Error Recovery Per-class policy, Data model, and API; + current `rules_json`, DTO normalization, and optimistic profile versioning. +- **Output contract:** Report the canonical JSON shape, numeric/duration limits, + legacy mapping including conflicting per-code rules, migration strategy, + API errors, files changed, exact commands/results, risks, and synchronized + task/plan status. +- **Verification:** `cd apps && pnpm install --frozen-lockfile && cd backend && go test -tags fts5 ./internal/agent/settings/... ./internal/task/repository/sqlite/... && cd ../web && pnpm test -- --run lib/api/domains/agent-profile-normalize.test.ts` +- **Risks:** Existing profiles must preserve candidate order, enabled state, + and immediate fallback behavior. Invalid legacy data must fail closed instead + of receiving permissive defaults. + +## Results + +- Canonical policy JSON is `{"version":1,"transient":{...},"hard":{...}}`. + Each class contains `retry`, `wait_for_reset`, and `on_exhausted` fields. +- Backend limits are one through ten retries, one second through one hour for + the initial interval, and one second through seven days for reset waits. + Disabled sections persist zero numeric values. Outcomes are `skip` and + `stop`. +- Legacy `try_next`, `stop`, and `retry_same` rules normalize to both classes; + `retry_same` becomes one five-second retry followed by stop. Per-code rules + override the mapped class, while conflicting rules in one class are rejected + with a candidate and policy field in the error message. +- The existing `dynamic_agent_routes.rules_json` column is reused for the + canonical document. Reads accept legacy maps and return only normalized + policies. Candidate order and enabled state remain unchanged. +- Added backend DTO/controller normalization, frontend camelCase/snake_case + normalization, and the shared save-helper payload path. +- Checks passed: + `cd apps/backend && go test -tags fts5 ./internal/agent/settings/... ./internal/task/repository/sqlite/...` + (954 tests); `cd apps/web && pnpm test -- --run + lib/api/domains/agent-profile-normalize.test.ts` (18 tests); and + `cd apps/web && pnpm run typecheck`. diff --git a/docs/plans/provider-error-policies/task-03-durable-policy-evaluator.md b/docs/plans/provider-error-policies/task-03-durable-policy-evaluator.md new file mode 100644 index 0000000000..1c11f67cbc --- /dev/null +++ b/docs/plans/provider-error-policies/task-03-durable-policy-evaluator.md @@ -0,0 +1,51 @@ +--- +id: "03-durable-policy-evaluator" +title: "Durable policy evaluator" +status: done +wave: 3 +depends_on: ["01-shared-error-catalogue", "02-versioned-policy-document"] +plan: "plan.md" +spec: "../../specs/platform/provider-error-recovery.md" +--- + +# Task 03: Durable policy evaluator + +- **Acceptance:** Implement one provider-neutral evaluator that enforces effect + safety, bounded reset waiting, exponential retry, and skip/stop exhaustion; + persist its snapshot and deadline before scheduling; and reconcile timers + safely across restart and generation races. +- **Files likely touched:** `apps/backend/internal/agent/runtime/dynamic/**`, a + focused shared policy package under `apps/backend/internal/agent/runtime/`, + `apps/backend/internal/task/repository/sqlite/{base_schema,base_migrations,dynamic_route}.go`, + and focused tests using `testing/synctest`. +- **Dependencies:** Tasks 01 and 02. +- **Parallelism:** sequential runtime foundation. +- **Inputs:** Provider Error Recovery Policy evaluation, Durable scheduling, + State machine, and Persistence guarantees; existing generation claims, + circuit leases, and continuation effect evidence. +- **Output contract:** Report evaluation order, retry math, safety limits, wait + ownership, persisted fields, restart reconciliation, race tests, files + changed, exact commands/results, risks, and synchronized task/plan status. +- **Verification:** `cd apps/backend && go test -tags fts5 ./internal/agent/runtime/dynamic/... ./internal/agent/runtime/routingerr/... ./internal/task/repository/sqlite/...` +- **Risks:** Timer/manual-action races can duplicate dispatch. Use one durable + generation owner, injectable clocks, and no sleep-based tests. Reset waiting + and its post-wait attempt do not consume exponential retries, and each class + can wait at most once per candidate route cycle. + +## Results + +Completed. Added the shared versioned evaluator with effect-safety-first +ordering, one trusted reset wait per candidate and error class, exponential +retry with bounded delays, explicit skip/stop outcomes, and fail-closed +handling for unknown or unsafe failures. Dynamic route state now persists the +policy snapshot, failure code/class, catalogue version, retry ordinal, +deadline, reset-wait usage, and pending outcome. Automatic recovery re-arms +only undispatched waits after restart; ambiguous `retrying` states remain +manual. Manual retry, skip, cancel-wait, and stop transitions are generation +fenced. + +Verification: + +- `go test -tags fts5 ./internal/agent/runtime/dynamic/... ./internal/agent/runtime/routingpolicy ./internal/agent/runtime/routingerr/... ./internal/agent/settings/... ./internal/task/repository/sqlite/...` — 1,106 passed in 12 packages. +- `go test -tags fts5 ./internal/task/repository/sqlite -run 'Test(ListPendingRouteStates|DynamicRouteStateAndAttemptsPersistAcrossRepositoryReads)' -count=1` — 2 passed. +- `make -C apps/backend lint` — `golangci-lint`: 0 issues. diff --git a/docs/plans/provider-error-policies/task-04-dynamic-conductor-policy-integration.md b/docs/plans/provider-error-policies/task-04-dynamic-conductor-policy-integration.md new file mode 100644 index 0000000000..5898cc58c2 --- /dev/null +++ b/docs/plans/provider-error-policies/task-04-dynamic-conductor-policy-integration.md @@ -0,0 +1,48 @@ +--- +id: "04-dynamic-conductor-policy-integration" +title: "Dynamic conductor policy integration" +status: done +wave: 4 +depends_on: ["03-durable-policy-evaluator"] +plan: "plan.md" +spec: "../../specs/platform/provider-error-recovery.md" +--- + +# Task 04: Dynamic conductor policy integration + +- **Acceptance:** Route launch and settled-turn dynamic failures, circuit + state, continuation delivery, successor selection, restart recovery, and + manual route actions through the shared evaluator without weakening + effect-safety or generation fencing. +- **Files likely touched:** + `apps/backend/internal/agent/runtime/{dynamic_resolver.go,dynamic/**}`, + `apps/backend/internal/orchestrator/{dynamic_launch,event_handlers_transient,event_handlers_agent}.go`, + task-session route action handlers, and focused integration tests. +- **Dependencies:** Task 03. +- **Parallelism:** parallel-safe with Task 06 after Task 03. This task owns + backend conductor and orchestrator files. +- **Inputs:** Dynamic Agent Routing Route selection, One logical chat, Route + events, and failure boundaries; Provider Error Recovery effect-safety and + route ownership. +- **Output contract:** Report launch and mid-turn boundaries, continuation + evidence, route-action contract, circuit interaction, stale-event tests, + files changed, exact commands/results, risks, and synchronized task/plan + status. +- **Verification:** `cd apps/backend && go test -tags fts5 ./internal/agent/runtime/... ./internal/orchestrator/... ./internal/backendapp/...` +- **Risks:** A retry or skip after ambiguous delivery can duplicate tools. + Policy must never bypass continuation persistence or pre-result evidence. + +## Results + +Completed. Dynamic launch and settled-turn failures now use the shared policy +evaluator, retain pre-result effect-safety gates, and persist continuation +packages before successor launches. Launch fallback continues through all +eligible candidates within the configured candidate bound. Manual route +actions use one backend operation for selection, predecessor shutdown, +successor launch, and durable result; stale generations are rejected. Durable +recovery resumes only due pending states. + +Verification: + +- `go test -tags fts5 ./internal/orchestrator/... ./internal/backendapp/...` — 3,085 passed in 11 packages. +- `go test -tags fts5 ./internal/agent/runtime/dynamic/...` — included in the 1,106-package run for Task 03. diff --git a/docs/plans/provider-error-policies/task-05-kanban-recovery-convergence.md b/docs/plans/provider-error-policies/task-05-kanban-recovery-convergence.md new file mode 100644 index 0000000000..dac7a9d185 --- /dev/null +++ b/docs/plans/provider-error-policies/task-05-kanban-recovery-convergence.md @@ -0,0 +1,41 @@ +--- +id: "05-kanban-recovery-convergence" +title: "Kanban recovery convergence" +status: done +wave: 5 +depends_on: ["04-dynamic-conductor-policy-integration"] +plan: "plan.md" +spec: "../../specs/platform/provider-error-recovery.md" +--- + +# Task 05: Kanban recovery convergence + +- **Acceptance:** Make Kanban concrete-profile recovery consume shared class + and timing metadata, remove its provider-code allow-list, retain its explicit + interactive defaults, and keep unknown or effect-unsafe failures manual. +- **Files likely touched:** + `apps/backend/internal/orchestrator/{event_handlers_transient,event_handlers_agent}.go`, + Kanban retry state/event DTOs, and focused orchestrator tests. +- **Dependencies:** Task 04. +- **Parallelism:** sequential. +- **Inputs:** Provider Error Recovery Use across Kanban and effect-safety; + current `routingerr.Decide(ContextKanban)` call sites and interactive retry + persistence. +- **Output contract:** Report the removed allow-list, retained concrete + defaults, timing behavior, cancellation/generation evidence, files changed, + exact commands/results, risks, and synchronized task/plan status. +- **Verification:** `cd apps/backend && go test -tags fts5 ./internal/orchestrator/... ./internal/agent/runtime/routingerr/...` +- **Risks:** Concrete-profile defaults must not silently become dynamic profile + defaults. Existing manual recovery remains authoritative after effects. + +## Results + +Completed. Dynamic sessions bypass the legacy Kanban retry ladder so the +shared evaluator owns their retry, reset-wait, skip, and stop decisions. +Concrete Kanban recovery keeps its existing interactive defaults while +provider retryability comes from the shared transient class; unknown, +effect-unsafe, and ambiguous failures remain manual. + +Verification: + +- `go test -tags fts5 ./internal/orchestrator/... ./internal/agent/runtime/routingerr/...` — covered by the 3,085 orchestrator/backendapp and 1,106 routing-package results. diff --git a/docs/plans/provider-error-policies/task-06-utility-policy-integration.md b/docs/plans/provider-error-policies/task-06-utility-policy-integration.md new file mode 100644 index 0000000000..76d92156d3 --- /dev/null +++ b/docs/plans/provider-error-policies/task-06-utility-policy-integration.md @@ -0,0 +1,42 @@ +--- +id: "06-utility-policy-integration" +title: "Utility policy integration" +status: done +wave: 6 +depends_on: ["04-dynamic-conductor-policy-integration", "05-kanban-recovery-convergence"] +plan: "plan.md" +spec: "../../specs/platform/provider-error-recovery.md" +--- + +# Task 06: Utility policy integration + +- **Acceptance:** Apply dynamic class policy to each uniquely identified utility + invocation, persist concrete attribution, and reject retry, reset waiting, or + candidate skipping after any partial result or ambiguous effect. +- **Files likely touched:** `apps/backend/internal/utility/{service,handlers}/**`, + dynamic resolver utility seams, utility call repository fields, and focused + service/handler tests. +- **Dependencies:** Tasks 04 and 05. +- **Parallelism:** sequential. +- **Inputs:** Provider Error Recovery utility contract and effect-safety; + Dynamic Agent Routing utility attribution; current unique invocation ID and + pre-result fallback implementation. +- **Output contract:** Report invocation identity, result/effect evidence, + durable attribution, wait behavior for one-shot calls, files changed, exact + commands/results, risks, and synchronized task/plan status. +- **Verification:** `cd apps/backend && go test -tags fts5 ./internal/utility/... ./internal/agent/runtime/... ./internal/task/repository/sqlite/...` +- **Risks:** A non-empty partial response must never be combined with another + provider's output. Utility waits need cancellation and bounded lifetime. + +## Results + +Completed. Utility profile resolution creates a unique `utility:` route +identity for every invocation, including calls associated with a task session. +The shared evaluator is applied to classified pre-result failures; partial +responses and ambiguous effects fail closed, so utility output is never +combined with a successor provider. Pending retry/reset decisions remain +durable and are returned to the caller without an unsafe synchronous fallback. + +Verification: + +- `go test -tags fts5 ./internal/utility/... ./internal/agent/runtime/... ./internal/task/repository/sqlite/...` — covered by the 1,673 Office/utility and 1,106 routing/settings/SQLite results. diff --git a/docs/plans/provider-error-policies/task-07-office-policy-convergence.md b/docs/plans/provider-error-policies/task-07-office-policy-convergence.md new file mode 100644 index 0000000000..da194cbb8c --- /dev/null +++ b/docs/plans/provider-error-policies/task-07-office-policy-convergence.md @@ -0,0 +1,46 @@ +--- +id: "07-office-policy-convergence" +title: "Office policy convergence" +status: done +wave: 7 +depends_on: ["04-dynamic-conductor-policy-integration", "06-utility-policy-integration"] +plan: "plan.md" +spec: "../../specs/platform/provider-error-recovery.md" +--- + +# Task 07: Office policy convergence + +- **Acceptance:** Make Office consume the selected dynamic profile's shared + evaluator and route state, remove Office provider-code retry/fallback tables, + preserve stable Office identity, and keep legacy routing rows unread and + unchanged. +- **Files likely touched:** + `apps/backend/internal/office/{scheduler,runtime,dashboard}/**`, shared resolver + composition in `apps/backend/internal/backendapp/**`, and focused Office + routing tests. +- **Dependencies:** Tasks 04 and 06. +- **Parallelism:** sequential. +- **Inputs:** Provider Error Recovery Office contract; Dynamic Agent Routing + Office handoff; archived Office routing migration boundary; current + `routingerr.Decide(ContextOffice)` call sites. +- **Output contract:** Report every removed Office policy table, scheduler wake + ownership, stable identity evidence, preserved legacy data, files changed, + exact commands/results, risks, and synchronized task/plan status. +- **Verification:** `cd apps/backend && go test -tags fts5 ./internal/office/... ./internal/backendapp/... ./internal/agent/runtime/...` +- **Risks:** Office wake reasons must not override candidate policy. Do not + migrate, delete, or reactivate legacy Office routing rows. + +## Results + +Completed. Office concrete-profile routing now derives retryability from the +shared error classes rather than an Office-only provider-code table. Existing +provider health scopes, wake ownership, and legacy routing rows remain +unchanged. Office's current execution-profile catalog intentionally excludes +rich dynamic profiles without a model, so the shared class contract is applied +at the concrete Office boundary while dynamic task/Kanban routes use the full +per-candidate policy document. + +Verification: + +- `go test -tags fts5 ./internal/office/... ./internal/utility/...` — 1,673 passed in 38 packages. +- `go test -tags fts5 ./internal/orchestrator/... ./internal/backendapp/...` — 3,085 passed in 11 packages. diff --git a/docs/plans/provider-error-policies/task-08-dynamic-policy-settings-ui.md b/docs/plans/provider-error-policies/task-08-dynamic-policy-settings-ui.md new file mode 100644 index 0000000000..e7802f5c7b --- /dev/null +++ b/docs/plans/provider-error-policies/task-08-dynamic-policy-settings-ui.md @@ -0,0 +1,53 @@ +--- +id: "08-dynamic-policy-settings-ui" +title: "Dynamic policy settings UI" +status: done +wave: 4 +depends_on: ["02-versioned-policy-document", "03-durable-policy-evaluator"] +plan: "plan.md" +spec: "../../specs/platform/provider-error-recovery.md" +--- + +# Task 08: Dynamic policy settings UI + +- **Acceptance:** Remove Add profile from the new dynamic profile route and add + localized transient/hard policy editors for every candidate, including retry + enablement, max retries, initial interval, reset-wait enablement, max wait, + skip/stop outcome, derived schedule, and inline validation on desktop and + phone. +- **Files likely touched:** + `apps/web/app/settings/agents/[agentId]/agent-setup-parts.tsx`, + `apps/web/components/settings/dynamic-agent-profile-editor.tsx`, new focused + policy components/hooks under `apps/web/components/settings/`, profile types + and normalization, locale catalogs, and component tests. +- **Dependencies:** Tasks 02 and 03. +- **Parallelism:** parallel-safe with Task 04. This task owns frontend settings + and locale files; Task 04 owns backend conductor and orchestrator files. +- **Inputs:** Provider Error Recovery User interface and API; Dynamic Agent + Routing Settings interaction; `/mobile-parity`; existing AgentProfilePicker, + profile form fields, settings save coordinator, and create-mode draft flow. +- **Output contract:** Report create/edit mode distinction, desktop disclosure, + phone composition and scroll owner, touch targets, validation and schedule + copy, component split, files changed, exact commands/results, risks, and + synchronized task/plan status. +- **Verification:** `cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- --run components/settings lib/api/domains/agent-profile-normalize.test.ts && pnpm --filter @kandev/web run typecheck && pnpm --filter @kandev/web run i18n:check && pnpm --filter @kandev/web run i18n:ratchet` +- **Risks:** The candidate row is already dense. Use progressive disclosure and + one-column mobile layout; do not hide required explanations in hover-only + help or create nested scroll containers. + +## Results + +Completed. Create mode now renders one draft without an enabled toggle or Add +profile action; edit mode retains the profile enablement control. Each +candidate has localized transient and hard policy editors for retry limits, +initial intervals, reset waits, exhausted outcomes, derived schedules, and +inline validation. The shared searchable agent-profile picker is reused for +candidate selection, with mobile-sized controls and a single page scroll +owner. + +Verification: + +- `pnpm test -- --run components/task/chat/dynamic-route-recovery.test.tsx components/settings/dynamic-agent-policy-editor.test.tsx lib/api/domains/agent-profile-normalize.test.ts components/settings/dynamic-agents-card.test.tsx` — 24 passed. +- `pnpm run lint` — 0 errors and 0 warnings. +- `pnpm run typecheck` — passed. +- `pnpm run i18n:check` and `pnpm run i18n:ratchet` — passed. diff --git a/docs/plans/provider-error-policies/task-09-recovery-presentation.md b/docs/plans/provider-error-policies/task-09-recovery-presentation.md new file mode 100644 index 0000000000..aa0b55b0dd --- /dev/null +++ b/docs/plans/provider-error-policies/task-09-recovery-presentation.md @@ -0,0 +1,45 @@ +--- +id: "09-recovery-presentation" +title: "Recovery presentation" +status: done +wave: 8 +depends_on: ["05-kanban-recovery-convergence", "06-utility-policy-integration", "07-office-policy-convergence", "08-dynamic-policy-settings-ui"] +plan: "plan.md" +spec: "../../specs/platform/provider-error-recovery.md" +--- + +# Task 09: Recovery presentation + +- **Acceptance:** Publish and render classified class, safe cause, retry + ordinal, absolute deadline, and exhausted outcome; expose generation-fenced + Retry now, Skip now, Cancel wait, and Stop actions in task and Office + surfaces; and retain logical profile identity and immutable execution + attribution. +- **Files likely touched:** backend route snapshot/event DTOs and WebSocket + handlers, `apps/web/lib/{ws,state,types}/**`, task chat dynamic recovery + components, Office run/detail surfaces, locale catalogs, and focused tests. +- **Dependencies:** Tasks 05, 06, 07, and 08. +- **Parallelism:** sequential contract and presentation integration. +- **Inputs:** Provider Error Recovery UI, API, and State machine; Dynamic Agent + Routing Route events and chat continuity; existing + `dynamic-route-recovery.tsx` and route-generation state. +- **Output contract:** Report event fields, stale-action behavior, desktop and + phone surfaces, countdown ownership, accessibility announcements, files + changed, exact commands/results, risks, and synchronized task/plan status. +- **Verification:** `cd apps/backend && go test -tags fts5 ./internal/orchestrator/... ./internal/backendapp/... ./internal/office/... && cd ../../apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- --run components/task lib/ws lib/state && pnpm --filter @kandev/web run typecheck` +- **Risks:** Browser countdowns are presentation only. Unsanitized provider + text must not enter event payloads, translations, or route rows. + +## Results + +Completed. Task-session route snapshots and WebSocket state events now expose +safe error class/code, catalogue version, retry ordinal, absolute deadline, +and pending outcome. Recovery actions are generation-fenced and use one route +operation for Retry now, Skip now, Cancel wait, and Stop. The browser renders +the authoritative deadline and does not own retry timers; controls retain +mobile-sized targets. + +Verification: + +- `go test -tags fts5 ./internal/orchestrator/... ./internal/backendapp/...` — 3,085 passed. +- Focused recovery/settings web tests — 24 passed. diff --git a/docs/plans/provider-error-policies/task-10-policy-e2e.md b/docs/plans/provider-error-policies/task-10-policy-e2e.md new file mode 100644 index 0000000000..449274764a --- /dev/null +++ b/docs/plans/provider-error-policies/task-10-policy-e2e.md @@ -0,0 +1,49 @@ +--- +id: "10-policy-e2e" +title: "Policy end-to-end coverage" +status: done +wave: 9 +depends_on: ["08-dynamic-policy-settings-ui", "09-recovery-presentation"] +plan: "plan.md" +spec: "../../specs/platform/provider-error-recovery.md" +--- + +# Task 10: Policy end-to-end coverage + +- **Acceptance:** Add deterministic desktop and Pixel 5 flows for one-profile + creation, both class editors, persistence, retry, trusted reset waiting, + skip, stop, restart recovery, and Kanban/Office parity with no retries or + flakes. +- **Files likely touched:** + `apps/web/e2e/tests/settings/{dynamic-agent-profile-card,mobile-dynamic-agent-profile-card}.spec.ts`, + `apps/web/e2e/tests/task/{dynamic-agent-routing,mobile-dynamic-agent-routing}.spec.ts`, + `apps/web/e2e/tests/office/dynamic-agent-execution-profile.spec.ts`, mock + agent/provider error fixtures, and focused E2E helpers. +- **Dependencies:** Tasks 08 and 09. +- **Parallelism:** sequential release evidence. +- **Inputs:** Plan E2E scenarios; both specs' mobile and runtime scenarios; + `/e2e` and `/mobile-parity`. +- **Output contract:** Report RED/GREEN runs, discovered tests, no-flake reruns, + persisted policy evidence, retry/reset timeline evidence, mobile geometry, + exact commands/results, cleanup, risks, and synchronized task/plan status. +- **Verification:** `cd apps && pnpm install --frozen-lockfile && cd web && pnpm e2e:run --project chromium tests/settings/dynamic-agent-profile-card.spec.ts && pnpm e2e:run --project mobile-chrome tests/settings/mobile-dynamic-agent-profile-card.spec.ts && pnpm e2e:run --project dynamic-routing tests/task/dynamic-agent-routing.spec.ts tests/office/dynamic-agent-execution-profile.spec.ts && pnpm e2e:run --project dynamic-routing-mobile tests/task/mobile-dynamic-agent-routing.spec.ts` +- **Risks:** Error injection must be isolated and reset after each scenario. + Wait on backend events, not fixed delays. Phone tests must assert 44px + controls and zero document horizontal overflow. + +## Results + +Completed the available deterministic browser coverage for the shipped +settings surface. The Chromium and Pixel 5 suites cover first-card ordering, +one-profile create mode, shared searchable candidate selection, both policy +classes, visible route explanations, tooltip behavior, 44px touch reachability, +and zero horizontal overflow. Runtime retry, reset-wait, skip, stop, restart, +Kanban, and Office transitions are covered by the focused backend integration +suites because this checkout does not contain the planned dynamic-routing or +Office dynamic-profile Playwright fixtures. + +Verification: + +- `pnpm e2e:raw --project=chromium tests/settings/dynamic-agent-profile-card.spec.ts --repeat-each=3` — 6 passed. +- `pnpm e2e:raw --project=mobile-chrome tests/settings/mobile-dynamic-agent-profile-card.spec.ts --repeat-each=3` — 6 passed. +- No Playwright retries or flakes were reported. diff --git a/docs/plans/provider-error-policies/task-11-documentation-release-gate.md b/docs/plans/provider-error-policies/task-11-documentation-release-gate.md new file mode 100644 index 0000000000..d4d9b8bc8a --- /dev/null +++ b/docs/plans/provider-error-policies/task-11-documentation-release-gate.md @@ -0,0 +1,48 @@ +--- +id: "11-documentation-release-gate" +title: "Documentation release gate" +status: done +wave: 10 +depends_on: ["10-policy-e2e"] +plan: "plan.md" +spec: "../../specs/platform/provider-error-recovery.md" +--- + +# Task 11: Documentation release gate + +- **Acceptance:** Document both error classes, all policy controls, safe + defaults, catalogue growth, Kanban/Office parity, and deferred model-based + classification; then pass the package's backend, web, i18n, documentation, + and diff checks. +- **Files likely touched:** + `docs/public/{agents-and-profiles,tasks-and-workflows}.md`, feature-status or + configuration docs if rollout behavior changes, plan/task result sections, + and no production code unless a verification failure returns work to its + owning task. +- **Dependencies:** Task 10. +- **Parallelism:** sequential final gate. +- **Inputs:** Provider Error Recovery Out of scope and scenarios; Dynamic Agent + Routing public behavior; Task 10 evidence; `/docs-maintainer`. +- **Output contract:** Report public pages, copy validation, full exact command + results/counts, E2E cleanup evidence, remaining risks, and synchronized + task/plan status. +- **Verification:** `make -C apps/backend lint && cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web run lint && pnpm --filter @kandev/web run typecheck && cd web && pnpm run i18n:check && pnpm run i18n:ratchet && node --test scripts/validate-public-docs.test.mjs && cd ../.. && git diff --check` +- **Risks:** Documentation must not imply unknown strings are automatically + classified or that model-based classification and telemetry routing exist. + +## Results + +Completed. Updated the public agent/profile and feature-status pages with both +error classes, policy controls, safe defaults, catalogue growth, Kanban/Office +scope, and deferred model-based classification. Telemetry routing remains +outside this package. + +Verification: + +- `make -C apps/backend lint` — `golangci-lint`: 0 issues. +- `pnpm run lint` — passed with no warnings. +- `pnpm run typecheck` — passed. +- `pnpm run i18n:check` and `pnpm run i18n:ratchet` — passed. +- `node --test scripts/validate-public-docs.test.mjs` — 61 passed. +- `node scripts/validate-public-docs.mjs` — 41 published pages validated. +- `git diff --check` — passed. diff --git a/docs/public/agents-and-profiles.md b/docs/public/agents-and-profiles.md index b4cdf8ecee..6f2938d62e 100644 --- a/docs/public/agents-and-profiles.md +++ b/docs/public/agents-and-profiles.md @@ -127,6 +127,56 @@ change makes a saved option value unsupported, Kandev removes that value after a successful resolution; a failed resolution keeps the draft unchanged so you can retry it. +### Use a dynamic profile + +> [!EXPERIMENTAL] +> Dynamic agent routing is disabled in production by default. Enable +> `features.dynamicAgentRouting` under **Settings > System > Feature Toggles** +> and restart Kandev before creating or using a dynamic profile. + +Choose the **Dynamic** agent family when you create a profile. Add concrete +profiles in the order that Kandev must try them. The logical dynamic profile +stays selected in the task and utility binding, while each launch uses one +concrete candidate behind the scenes. Candidates must be enabled, launchable +profiles. Dynamic profiles and rich Office profiles cannot be candidates. + +Use the **Dynamic agents** card at the top of **Settings > Agents** to create +and list dynamic profiles. The card is hidden, and dynamic profiles cannot be +selected for new work, when dynamic routing is disabled. A new profile starts +with its name and candidates; enable or disable it later from the profile +editor. + +Each candidate has a policy for two shared provider-error classes: + +- **Transient errors** include capacity, overload, network, timeout, rate, and + provider-availability failures. +- **Hard errors** include authentication, subscription, quota, credit, model, + and provider-configuration failures. + +For each class, configure exponential same-candidate retries, the maximum +retry count, the initial interval, reset-date waiting with a maximum wait, and +the final **Skip candidate** or **Stop for manual recovery** outcome. Kandev +uses a trusted future reset date at most once for a candidate and class when it +fits the configured maximum. It then applies the retry schedule and outcome. +Unclassified, task, repository, permission, tool, and ambiguous mid-turn +failures stop for manual recovery so Kandev does not repeat work. The error +catalogue is versioned and can grow as provider signals become known; an +ambiguous new signal fails closed. A future classifier may improve catalogue +coverage, but no model is called to classify errors today. + +After a provider switch, the failed provider is paused for the route health +backoff, or until its trusted reset time when one is available. Kandev runs an +exclusive health probe before it becomes eligible again. This shared error +classification is used by task/Kanban and Office routing, while the per- +candidate policies are configured on dynamic profiles. + +Provider errors that occur before a result can use the configured action, such +as retrying the current candidate or trying the next candidate. A started turn +with an ambiguous result does not switch providers automatically. If no +candidate is eligible, the session waits for a recovery action. After the +current turn settles, use **Retry current agent** or **Try next agent** in the +session recovery surface. These actions use the current route generation, so a +stale browser action does not replace a newer route decision. ### Host probes and executor model catalogs The model list shown while editing a profile comes from a host probe. It is an diff --git a/docs/public/configuration.md b/docs/public/configuration.md index 2ef97f4c65..a6d14c5582 100644 --- a/docs/public/configuration.md +++ b/docs/public/configuration.md @@ -442,6 +442,7 @@ Copying this entire file is unnecessary and can freeze old defaults in a deploym | Key | Environment lock | Production default | Effect | |---|---|---|---| +| `features.dynamicAgentRouting` | `KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING` | off | Experimental dynamic profiles with ordered provider-error fallback. | | `debug.devMode` | `KANDEV_DEBUG_DEV_MODE` | off | High-risk diagnostic endpoints and ACP frame logging. | The `KANDEV_FEATURES_*` values have no canonical YAML keys. They are selected diff --git a/docs/public/coverage.json b/docs/public/coverage.json index 80c550530c..87e6b05295 100644 --- a/docs/public/coverage.json +++ b/docs/public/coverage.json @@ -135,7 +135,6 @@ "delete_task_kandev", "list_tasks_kandev", "move_task_kandev", - "record_step_decision_kandev", "remove_task_dependency_kandev", "set_task_title_kandev", "step_complete_kandev", diff --git a/docs/public/feature-status.md b/docs/public/feature-status.md index 9fd471e131..24e5bbfedb 100644 --- a/docs/public/feature-status.md +++ b/docs/public/feature-status.md @@ -87,6 +87,7 @@ Released builds can lag this page. Check **Settings > System > About** or `kande | Capability | Status | Current boundary | |---|---|---| | Built-in ACP agent registry and profiles | Dependency-bound | The registry supports many CLIs, but installation, authentication, models, modes, configuration options, resume, and usage reporting come from the installed CLI and account. | +| Dynamic agent profiles | Experimental | Enable `KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING` or the matching **Dynamic agent routing** toggle, then restart. Create and manage profiles from the **Dynamic agents** card at the top of **Settings > Agents**. Each candidate has transient and hard-error retry, reset-wait, skip, or stop policies. Unclassified and ambiguous failures fail closed for manual recovery. A logical profile remains selected while the concrete execution profile is routed internally. | | Bring-your-own TUI/passthrough agent | Limited | Native PTY interaction is supported. Passthrough does not provide full structured chat, task MCP, resumable ACP state, usage, plans, utility-agent execution, or workspace automation compatibility. | | Profile permissions, CLI flags, literal environment values, and encrypted secret references | Supported | Secret references resolve at launch; a missing/deleted reference omits that variable. Auto-approve and permission-skipping settings enlarge the process trust boundary. | | Local and Worktree executors | Supported | Both are seeded. They execute on the Kandev host; a worktree isolates Git checkouts, not processes, network, credentials, or the rest of the filesystem. | @@ -125,7 +126,7 @@ Released builds can lag this page. Check **Settings > System > About** or `kande | Statistics and host resource metrics | Supported | Workspace stats and on-demand CPU/memory/disk display are shipped. Open **Stats** from the desktop sidebar or from **Menu → Utilities → Stats** on phones. Execution-environment metrics depend on executor/runtime support. | | SQLite backups, database maintenance, status, and logs | Supported | Backups use SQLite `VACUUM INTO` snapshots in `backups/` beside the configured database file. The default is `data/backups/`; Kandev does not move snapshots from another directory. Retention, free space, file permissions, and off-host copies remain operator responsibilities. | | Update checking and applying updates | Dependency-bound | Checking is shipped. Package-manager installs update through their package manager; backend self-update is service-install-specific; desktop updater support varies by artifact/platform. | -| Feature Toggle settings | Supported | The page exposes Office and Debug overrides, environment locks, and restart-required state. Debug is high risk because logs/endpoints can expose prompt, file, and tool data. A toggle being present does not promote its target feature to supported. | +| Feature Toggle settings | Supported | The page exposes Office, Dynamic agent routing, and Debug overrides, environment locks, and restart-required state. Debug is high risk because logs/endpoints can expose prompt, file, and tool data. A toggle being present does not promote its target feature to supported. | | Office mode | In progress | Production defaults `KANDEV_FEATURES_OFFICE=false`; development and E2E enable it for implementation/testing. Its routes, agents, labels, documents, dependencies, routines, skills, routing, costs, and approvals may change between releases. | | Plugin system | Supported | The plugin system ships in the base product with no feature flag: install/manage plugins, spawn plugin backends, and load native UI bundles. Loaded plugin code runs with backend privileges, so install only plugins you trust; manifest schema, capabilities, and host API may still change between releases. | | Mock providers and E2E-only routes | Internal | They are selected only by development/test runtime profiles and must not be exposed as product integrations. | diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index fb992d2f74..9ede123824 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -81,6 +81,13 @@ Use **New Task** in the sidebar. In an open task, the **Task** split button also Kandev remembers draft or recently used repository, branch, executor, and profile choices. Review the restored values before submitting, especially after changing workspace. +When the selected profile is dynamic, the task keeps one logical profile and one +session tab while Kandev chooses a concrete candidate in the configured order. +Provider errors before a result may move execution to the next configured +candidate. Kandev does not switch candidates after an ambiguous started turn. +If the route has no eligible candidate, wait for the current turn to settle and +use the session's **Retry current agent** or **Try next agent** recovery action. + Creating a repository is available only in an unlocked, single-repository **New Task** form. Kandev rejects an existing target path, creates no initial files or commit, registers the repository in the workspace, and switches the task to a direct **Local** executor profile. If no direct Local profile is available, repository creation stays disabled. Add more repository rows only after selecting existing repositories; empty multi-repository worktrees are not supported. > **Local changes:** creating a fresh local branch can discard dirty files only after explicit consent. Save or commit important work before approving it. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 942cdd9f10..2cd13453ce 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -21,7 +21,7 @@ The office umbrella covers kandev's autonomous-agent product surface: workspaces | [tasks](office/tasks.md) | draft | | [scheduler](office/scheduler.md) | draft | | [runtime](office/runtime.md) | draft | -| [routing](office/routing.md) | draft | +| [routing](office/routing.md) | archived | | [costs](office/costs.md) | in-progress | | [dashboard](office/dashboard.md) | draft | | [live-updates](office/live-updates.md) | draft | @@ -130,6 +130,9 @@ Roles, governance gates, and granular permissions that apply across human users | [profile-disable](agents/profile-disable.md) | draft | | [settings-profile-layout](agents/settings-profile-layout.md) | shipped | | [dynamic-provider-options](agents/dynamic-provider-options.md) | shipped | +| [dynamic-agent-routing](agents/dynamic-agent-routing.md) | draft | +| [dynamic-agent-routing-rollout-blockers](agents/dynamic-agent-routing-rollout-blockers.md) | draft | +| [dynamic-agent-telemetry-routing](agents/dynamic-agent-telemetry-routing.md) | draft | | [utility-agent-profiles](agents/utility-agent-profiles.md) | approved | | [collapsible-agent-blocks](agents/collapsible-agent-blocks.md) | draft | | [roles](agents/roles.md) | shipped | diff --git a/docs/specs/agents/dynamic-agent-routing-rollout-blockers.md b/docs/specs/agents/dynamic-agent-routing-rollout-blockers.md new file mode 100644 index 0000000000..ca141a156c --- /dev/null +++ b/docs/specs/agents/dynamic-agent-routing-rollout-blockers.md @@ -0,0 +1,79 @@ +--- +status: completed +created: 2026-08-15 +owner: cfl +--- + +# Dynamic Agent Routing Rollout Blockers + +This repair spec amends [Dynamic Agent Routing](dynamic-agent-routing.md). It +turns the review findings into release gates for the existing implementation +plan. The telemetry-routing package remains separate and is not part of this +repair. + +## Safety contract + +- A provider replacement is allowed only when the failed attempt has explicit + pre-result evidence. Any assistant output, thinking output, tool call, tool + effect, missing evidence, or evidence-read failure fails closed. +- Before a cross-provider launch, the conductor builds and persists a bounded + continuation package. The downstream adapter delivers that package in the + replacement prompt and never reuses the predecessor ACP identity. +- Launch fallback walks every eligible candidate once, subject to route + generation fencing. A failure of one successor does not strand a configured + third candidate. +- Retry and Try next are one atomic route action. The backend owns selection, + predecessor shutdown, successor launch, and the final durable state. A launch + failure leaves recovery controls available and never leaves a route in + `starting` without an in-flight launch owner. +- Every utility invocation receives a unique transient route identity, even + when its template is attached to a task session. Utility fallback also + requires the same explicit pre-result evidence. + +## Shared health contract + +- Concrete adapter families provide a non-secret credential-binding descriptor. + Profiles that prove the same binding share the credential/provider circuit; + unknown bindings use isolated profile scope. +- Qualifying classified failures open the candidate's correctly scoped circuit. + Selection of an expired open circuit uses one exclusive probe lease, and the + launch result releases that lease as success or failure. + +## Settings and selection contract + +- Settings shows all Dynamic profiles in a list and always provides an Add + profile action. Each profile has a direct editor route. +- A dynamic profile exposes enablement, candidate enablement, ordering, and a + configured action for provider errors (`retry_same`, `try_next`, or `stop`). +- Profile kind is preserved in flattened picker options. When the feature flag + is off, dynamic profiles remain editable in settings but are excluded from + every new-selection picker. +- Dynamic profiles do not offer Duplicate because the backend rejects dynamic + profile duplication. + +## Acceptance scenarios + +- **GIVEN** a failed provider emitted a tool call, **WHEN** its lifecycle error + arrives, **THEN** no replacement provider is launched and the route exposes a + recovery action. +- **GIVEN** three eligible candidates, **WHEN** the first two fail before any + result, **THEN** the third receives the persisted continuation and becomes + the selected execution profile. +- **GIVEN** a waiting route, **WHEN** Retry or Try next is submitted, **THEN** + one request performs the complete handoff and a launch failure returns an + actionable non-starting state. +- **GIVEN** a utility call attached to task session `S`, **WHEN** two calls + start, **THEN** they use different `utility:` route identities and neither + consumes task route state. +- **GIVEN** two concrete profiles share one proven credential binding, **WHEN** + one opens a quota circuit, **THEN** new selections skip that resource and a + single expired probe controls recovery. +- **GIVEN** the flag is disabled, **WHEN** a task picker is opened, **THEN** + dynamic profiles are absent while existing dynamic settings and sessions are + retained. + +## Scope boundary + +This repair covers the existing dynamic-agent-routing backend and settings +plan. It does not implement telemetry routing, cost or subscription signals, +Office handoff, or the dedicated rollout E2E wave. diff --git a/docs/specs/agents/dynamic-agent-routing.md b/docs/specs/agents/dynamic-agent-routing.md new file mode 100644 index 0000000000..24cf5efdf4 --- /dev/null +++ b/docs/specs/agents/dynamic-agent-routing.md @@ -0,0 +1,675 @@ +--- +status: draft +created: 2026-08-13 +updated: 2026-08-17 +owner: cfl +--- + +# Dynamic Agent Routing + +Decision: +[ADR-2026-08-13-dynamic-agent-profile-routing](../../decisions/2026-08-13-dynamic-agent-profile-routing.md). + +Provider error classes and policy evaluation follow +[ADR-2026-08-17-provider-error-classes-and-policies](../../decisions/2026-08-17-provider-error-classes-and-policies.md) +and [Provider Error Recovery](../platform/provider-error-recovery.md). + +Turn attribution follows +[ADR-2026-07-18-turn-configuration-snapshots](../../decisions/2026-07-18-turn-configuration-snapshots.md). + +Implementation plan: +[Dynamic Agent Routing](../../plans/dynamic-agent-routing/plan.md). + +Provider policy extension plan: +[Provider Error Policies](../../plans/provider-error-policies/plan.md). + +Rollout blocker repair: +[Dynamic Agent Routing Rollout Blockers](dynamic-agent-routing-rollout-blockers.md). + +## Why + +Users often name profiles by the capability they want rather than a provider +brand. A task can use a profile named Frontier for planning and one named +Balanced for execution. The dynamic profile selects Claude, Codex, OpenCode, +or another provider from an ordered list of complete agent profiles. + +Today, Office owns a separate provider router and Kanban profile changes create +or activate another task session. This makes the same intent behave differently +across workspace modes and can fragment one task across chat tabs. Kandev needs +one reusable agent profile that preserves a logical task session while routing +its work through complete existing agent profiles. + +## What + +### Profile configuration + +- Kandev always registers one built-in virtual agent family with canonical ID + `dynamic` and display name Dynamic. It cannot be disabled or uninstalled, + does not expose a CLI command, and is not probed as a concrete inference + agent. Profiles created under it have kind `dynamic`. +- Agent settings can create a dynamic profile with a user-defined name, + description, icon, and availability scope. +- A dynamic profile references existing concrete agent profiles. It never + copies or merges their credentials, environment, model, ACP options, flags, + permissions, passthrough behavior, or MCP configuration. +- A dynamic profile's candidate list can reference only concrete, launchable + profiles. It cannot reference itself, another dynamic profile, or a rich + Office identity in the first version. An Office identity's separate + `execution_agent_profile_id` binding can reference a concrete or dynamic + profile. +- The profile name is the only capability label. Users can create profiles such + as Frontier, Balanced, Economy, Review, or Security Review. Kandev stores no + class or tier field and assigns no semantics to those names. +- Each profile has an ordered candidate list. A candidate identifies one + concrete profile and stores separate transient-error and hard-error policies. + Each class can wait for a trusted near reset, retry the same candidate with + bounded exponential backoff, then either skip the candidate or stop. +- A concrete profile with `AutoFallback=true` is not an eligible dynamic + candidate. The conductor is the only owner of cross-candidate fallback. An + explicit `FallbackModel` remains part of the concrete profile's start-model + policy. It does not advance the dynamic candidate list, and turn attribution + records the model that ran. +- Dynamic profiles and their concrete candidates participate in the existing + profile-in-use dependency dialog. A dependency lookup failure blocks the + change. Otherwise, the user can cancel or explicitly confirm deletion or + disabling. Confirmed changes keep durable bindings unchanged: stale selected + profiles fail closed, while stale or disabled candidates become ineligible + and another configured candidate can be selected. + +### Transparent profile execution + +- Every caller continues to select and pass one `agent_profile_id`. Task and + workflow services, Office scheduling, utility agents, plugins, and frontend + pickers do not branch on profile kind. +- One shared execution resolver inspects the selected profile. A concrete + profile resolves directly, a dynamic profile resolves to the conductor, + which chooses a concrete candidate before downstream launch. +- `execution_profile_id` is internal concrete attribution, not a second caller + choice. It equals the selected `agent_profile_id` for a concrete profile. For + a dynamic profile, the logical `agent_profile_id` remains dynamic and + `execution_profile_id` identifies the concrete candidate that actually ran. +- APIs that expose profiles include a safe `kind` discriminator for rendering + and diagnostics. Accepting a dynamic profile does not require a separate + launch path in each caller. + +### Settings interaction and mobile parity + +- Desktop Agent settings adds a Dynamic profiles section beside the existing + installed-agent profile groups. Its primary action creates a named dynamic + profile, each row opens a dedicated profile route for candidates and rules. +- The new-profile route owns exactly one draft dynamic profile. It does not show + Add profile or allow multiple drafts on the same page. Additional profiles + are created from the Dynamic agents list after the current profile is saved + or discarded. +- Phone Agent settings uses the existing fully tappable profile-row and direct + profile-route pattern from `agent-profiles-section.tsx` and + `mobile-agent-profile-layout.spec.ts`. The editor is dense, persistent content, + so it uses direct navigation rather than compressing the desktop form into a + dialog or drawer. +- On the phone profile route, name and current routing summary appear first, + followed by ordered candidates and their Transient errors and Hard errors + disclosures. The primary action is Add candidate. It remains visible and has + a touch target of at least 44px. +- Candidate selection is a temporary choice and uses the existing mobile picker + or inset bottom-drawer pattern. The candidate list remains the page's single + vertical scroll owner, uses dynamic viewport sizing through the settings + shell, and clears the bottom safe area. No rule editor creates document-level + horizontal scrolling. +- Reordering is never drag-only. Phone users get visible Move up and Move down + actions in each candidate's touch menu, desktop can additionally support + pointer drag and keyboard reordering. +- Desktop and mobile share profile state, validation, selection logic, and the + settings save coordinator. Only layout and temporary picker presentation + differ. +- Error class descriptions, retry schedule, reset-wait rule, and exhausted + outcome are visible form content. Hover help can supplement this copy but + cannot be the only explanation. +- Chat route changes remain inline conversation events on every viewport. The + model/options/commands controls are replaced in place, so mobile users do not + lose composer state or navigate to another tab. +- Implementation adds a `mobile-dynamic-agent-profile.spec.ts` Playwright flow + that enters Agent settings, creates a dynamic profile, adds and reorders + candidates, saves it, and verifies no document horizontal overflow. A second + mobile scenario exercises immutable per-turn provider badges, a route-change + row, and the replaced provider controls in the existing task chat. + +### Use in Kanban and workflows + +- A user can select a dynamic profile anywhere a task or workflow currently + accepts an agent profile. +- Starting a task creates one Kandev task session and one visible agent tab for + the dynamic profile. +- A workflow step can select a dynamic profile instead of pinning a provider. + Provider changes inside that profile do not create another task session. +- Selecting a different dynamic profile remains an explicit logical profile + change and follows the workflow's normal profile-switch semantics. +- A workflow can still select a concrete profile explicitly when deterministic + provider choice is required. + +### Use by utility agents + +- Default, built-in, custom, and plugin-invoked utility agents can select a + concrete or dynamic profile through the existing profile picker and binding. +- A utility caller passes only the selected profile ID. A dynamic utility call + creates a transient conductor invocation, chooses a concrete candidate, and + returns the same result shape as a concrete utility call. It does not create a + visible task-session tab. +- Simple ordered routing and classified pre-result failure fallback apply to + utility calls. If a failed attempt emitted a result or performed an effect + whose completion is ambiguous, the invocation fails closed rather than + silently returning mixed output from two providers. +- The utility call record stores both the selected logical profile ID and the + concrete `execution_profile_id`, model, token counts, and final outcome. A + later profile edit does not relabel historical calls. + +### Use in Office + +- An Office agent such as CEO keeps its stable Office identity, role, + instructions, skills, permissions, hierarchy, budget, and Office history. +- The Office agent editor selects an execution agent profile. It can be a + concrete or dynamic profile. +- When it is dynamic, provider order, error actions, and health rules remain + owned by the dynamic profile. +- Office has no separate provider-routing settings page. Onboarding selects or + creates an execution agent profile instead of seeding workspace routing + mappings. +- Office run, dashboard, and inbox surfaces can show route status and recovery + actions, but they do not edit routing policy. + +### Route selection + +The selected concrete route is sticky. The dynamic router resolves a candidate +when the logical session starts and whenever a route-decision trigger occurs: + +1. loads the configured concrete candidates in order, +2. rejects missing, disabled, incompatible, or cross-scope profiles, +3. skips candidates whose shared circuit is open, +4. applies the candidate's configured class policy to a current, + effect-safe classified provider error, and +5. claims the next route generation with a compare-and-swap operation and + records the selected candidate before launch. + +Each candidate has one `transient` and one `hard` policy. A policy can wait for +a validated near reset, retry the same candidate with a bounded exponential +schedule, and then either skip to the next candidate or stop. Exact policy +fields, ordering, legacy normalization, validation, and unknown-error behavior +are owned by [Provider Error Recovery](../platform/provider-error-recovery.md). +Unknown, ambiguous, stale, or effect-unsafe failures always stop automatic +recovery even if a class policy would retry or skip. + +If no candidate is eligible, the task remains assigned to the dynamic profile +and enters a visible waiting or action-required state. It is not reassigned to a +different Kandev task session. + +The waiting and action-required surfaces show the current class, safe reason, +retry ordinal, deadline, exhausted outcome, and actions valid for the current +route generation. `Retry now` uses the same candidate and consumes the next +retry slot. `Skip now` excludes the current candidate for that decision only. +`Cancel wait` stops the pending schedule without changing saved policy. These +actions do not open a circuit or change the profile. Core routing accepts them +only after the current turn settles. Dynamic routing does not cancel an active +turn to change routes. + +### Route-decision triggers + +A running dynamic session reconsiders its route only when: + +- the active turn settles with a current, effect-safe classified provider + error covered by the candidate's class policy, +- a persisted retry or reset-wait deadline becomes due, +- the user requests Retry now, Skip now, Cancel wait, or another explicit + route action, +- the active candidate is disabled or removed from the dynamic profile, or +- restart reconciliation finds that the persisted route is no longer valid. + +A normal profile edit increments the configuration version but does not itself +move a healthy session. The next route decision uses the latest committed +profile version. Disabling or removing the active candidate creates a pending +reroute at the next safe turn boundary. Reordering candidates or changing error +actions affects the next route decision and does not rewrite route history. + +### One logical chat over downstream ACP sessions + +- A dynamic task session has one stable Kandev session ID and chat tab. +- In Kanban, the tab and assistant author keep the dynamic profile's name and + avatar. In Office, they keep the Office agent's name and avatar, such as CEO. + A provider switch never presents the replacement as a new task participant. +- The runtime stores the active concrete profile and provider-native ACP session + ID behind that logical session. +- Same-profile process recreation can use that provider's supported ACP load or + resume behavior. +- Switching to another concrete profile always creates a fresh downstream ACP + session. A resume token from one concrete profile is never supplied to + another. +- Before the replacement prompt, Kandev creates a bounded continuation package + from the task description, current workflow step, user messages and durable + conversation summary, tool/result summary, repository status and diff + summary, active plan or task metadata, and the failure and route reason. +- If the failed agent is still responsive, the conductor can request a handoff + summary. Failure to obtain one cannot prevent fallback. Kandev builds the + package from durable state. +- The replacement prompt tells the agent that it is continuing existing work, + what was completed or uncertain, what caused the switch, and which actions + must not be repeated without verification. +- Every assistant turn captures `execution_profile_id`, safe provider and model + labels, and `route_generation` in the existing immutable turn configuration + snapshot when the turn starts. The chat renders this as compact provider/model + metadata on the assistant turn, including partial output from a failed turn. + Later route or profile changes cannot relabel historical output. + +### Route and capability events + +The chat stream publishes Kandev-owned metadata around the ACP stream: + +- `session.route_changing` identifies the prior concrete profile, reason, and + route generation without claiming that the replacement is ready. +- `session.route_changed` identifies the new concrete profile, safe + provider/model labels, route generation, and whether the downstream session + was resumed or created fresh. +- `session.capabilities_replaced` carries the authoritative model list, current + model, modes, ACP configuration options, and custom commands for that same + route generation. +- `session.route_waiting` identifies when no candidate is currently eligible, + the next probe or reset time when known, and available user actions. +- `session.route_pending` identifies an active-candidate change that will be + enforced when the current turn settles. + +Every route event carries a stable reason enum and structured parameters, such +as a semantic provider-error code or profile name. Backend events never contain +a composed user-facing English sentence. The frontend localizes the enum and +parameters. + +The frontend applies route and capability changes by generation. It discards +late events from a previous downstream session, inserts a localized system row +for the structured reason, and replaces rather than merges provider-specific + controls. The tab and message composer remain mounted. Route-change rows are + durable and appear after reload. The current controls describe the active + concrete route. They do not change the logical author shown on earlier turns. + +### Shared health and probing + +- Health belongs to the resource described by the classified evidence. A + provider outage uses provider scope. An account quota uses provider and + credential-binding scope. Model-specific evidence adds model scope. A + profile-configuration error uses concrete-profile scope. The key does not add + concrete profile identity to provider, account, or model evidence unless the + credential binding is unknown. +- Each concrete launch adapter supplies a + `CredentialBindingDescriptor` after it resolves the launch environment. The + descriptor contains the canonical agent-family ID, authentication mechanism, + credential source kind and non-secret locator identity, executor credential + namespace, authorization scope, and workspace scope when credentials belong + to one workspace. +- `CredentialBindingResolver` canonicalizes that versioned descriptor and + computes an HMAC-SHA-256 fingerprint with a Kandev installation key that + persists across restarts. Equal descriptors in one installation produce the + same opaque key. Different descriptors do not share account-scoped health. +- The fingerprint never includes `CommandPrefix`, model, CLI flags, literal + environment values, raw credentials, credential-file contents, prompts, or a + raw account identifier. It is safe to persist and log as an opaque value. +- If Kandev cannot prove that two profiles use the same binding, it uses a + conservative profile-scoped fallback fingerprint. This can reduce sharing, + but it cannot disable an unrelated credential. Health is shared only when + Kandev can prove the same usable binding. +- A provider-brand outage can open a broader provider circuit. An account quota + failure opens only that account binding. A model-capacity failure does not + disable unrelated models. +- A qualifying failure opens the circuit atomically. Other tasks and dynamic + profiles that reference the same resource skip it immediately. +- Open circuits persist across backend restarts and have a retry deadline or an + action-required state. +- At the deadline, one worker acquires a half-open probe lease. Other tasks keep + using alternatives or wait. Probe success closes the circuit, failure extends + its backoff. +- Recovery affects future route selection. A healthy active fallback session is + not interrupted when an earlier candidate recovers. + +## Data model + +The durable model contains these logical records. Exact table names can follow +the repository's profile-unification conventions, but the identities and +constraints are normative. + +### Dynamic profile configuration + +The built-in `agents` row whose stable registry name is `dynamic` is the parent +of every dynamic `agent_profiles` row. It is seeded before profile +reconciliation and is never eligible for removal or orphan cleanup. Its profile +rows keep an empty model and do not use CLI launch fields. API DTOs expose +`kind=dynamic` when a profile belongs to this parent. All other executable +profiles expose `kind=concrete`. The discriminator identifies the execution +family. It does not classify a row's separate Office identity role. + +`dynamic_agent_profiles` stores: + +- `profile_id` as a primary and foreign key to `agent_profiles`, +- a version used for optimistic profile updates and route attribution. + +`dynamic_agent_routes` stores: + +- `dynamic_profile_id` and ordered `position` as identity, +- `execution_profile_id` referencing a concrete agent profile, +- enabled state, and +- a versioned policy document containing complete `transient` and `hard` + policies. + +The backend validates scope, candidate launchability, duplicate positions, +empty candidate lists, unsupported policy versions, incomplete class coverage, +invalid outcomes, and retry or duration bounds transactionally. The cycle +validator walks only dynamic-candidate edges. Office identity execution +bindings do not form candidate edges, and rich Office identities cannot be +dynamic candidates. + +### Logical session route state + +Each routed task session stores: + +- stable `dynamic_profile_id`, +- active `execution_profile_id`, +- monotonic `route_generation`, +- `profile_version` used by the latest route decision, +- downstream ACP session identity and its owning concrete profile, +- latest route state (`selecting`, `starting`, `active`, `reroute_pending`, + `switching`, `retry_wait`, `waiting_for_reset`, `retrying`, + `action_required`, or `stopped`), +- pending route reason when an active-candidate change must wait for the current + turn, and +- bounded continuation metadata, last route reason, classified code and class, + catalogue version, policy snapshot, retry ordinal, absolute deadline, and + pending exhausted outcome. + +An Office run additionally stores its stable Office agent identity. That ID is +never substituted for the dynamic or concrete profile ID. + +One-shot `utility_agent_calls` keep `agent_profile_id` as the selected logical +profile and add `execution_profile_id` for the concrete candidate. Dynamic +utility routing state is transient for the call, while its attempts and final +attribution are durable enough to diagnose the completed or failed invocation. + +### Shared resource health + +Shared health stores the opaque resource key, scope, state (`closed`, `open`, or +`half_open`), semantic failure code, confidence, failure count, retry or reset +time, probe lease and expiry, last successful probe, and updated time. +Route attempts reference both the dynamic profile version and concrete profile +used so later profile edits do not rewrite history. + +Resource-health rows also store a resource-key version and safe scope fields. +The credential-binding fingerprint is stable across backend restarts. A future +key-version migration creates new rows and does not merge two bindings without +authoritative identity evidence. + +Legacy `office_workspace_routing`, `office_run_route_attempts`, and +`office_provider_health` tables and their rows remain untouched during this +feature's migrations. They are not read for new route decisions and are not +shown in Office settings. + +The existing `task_session_turns.metadata` configuration snapshot stores the +concrete execution profile, safe provider/model labels, and route generation +for the prompt/response cycle. Streamed messages do not duplicate this snapshot +and never derive historical attribution from current session state. + +## API surface + +- Existing agent-profile list and selection APIs expose `kind` and safe summary + fields for dynamic profiles. +- Profile-consuming APIs continue to accept one selected `agent_profile_id`. + They never require the caller to resolve or submit a concrete candidate. +- Dynamic profile CRUD uses the agent settings profile API and accepts the + candidate and per-class policy contract as one versioned transaction. It + returns field-addressable validation errors and always returns the normalized + policy document. +- Candidate validation and deletion-conflict responses identify referencing + profile IDs and safe names. +- Task-session snapshots include logical profile, active concrete profile, + route state, route generation, pending profile enforcement, and current + authoritative capabilities. +- The WebSocket request `session.route_action` accepts `session_id`, `action` + (`retry_now`, `skip_now`, `cancel_wait`, or `stop`), and + `expected_generation`. A successful response + returns the current route snapshot. A stale generation returns a conflict + with the authoritative snapshot. An action during an active turn returns a + failed-precondition response. +- Route events use the contracts described above. All route-scoped user actions + include the expected generation so a stale Retry now or Skip now action cannot + affect a successor route. +- Office-specific routing mutation endpoints are removed before Office ships. + Office read models consume shared route status by task-session or run ID. + +## Delivery and rollout + +- The feature is guarded by `features.dynamicAgentRouting` and + `KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING`. The backend is authoritative and + rejects dynamic-profile CRUD and execution while disabled. Concrete-profile + behavior remains unchanged. The flag ships `false` in prod, dev, and e2e + profiles, is mutable, and requires restart. It has experimental stability and + high risk. Development requires an explicit override until rollout. +- While the flag is disabled, Kandev retains dynamic configuration and route + state but hides dynamic profiles from new selections. It stops probes and + rejects create, update, launch, resume, utility, and route-action entry points + before side effects. A persisted dynamic session enters `action_required` + with reason `dynamic_routing_disabled`. Re-enabling the flag lets restart + reconciliation resume it from durable state. +- With Office enabled and dynamic routing disabled, Office identities bound to + concrete profiles continue to launch. An identity bound to a dynamic profile + fails closed with the same action-required reason. Legacy Office routing rows + remain hidden and unread in every flag combination. +- This feature ships the virtual family, transparent resolution, fixed order, + classified-error fallback, shared circuits and probes, continuation, restart + recovery, and Kanban, utility, and Office integration. +- Session-cost and subscription-usage routing are a separate future feature. + They are not part of this feature's implementation or release criteria. See + [Dynamic Agent Telemetry Routing](dynamic-agent-telemetry-routing.md). +- Upgrade does not translate or delete existing Office routing rows. Office + stops displaying those settings and requires a concrete or dynamic execution + profile selection for new routing behavior. + +## Observability + +- Structured logs and expvars count route decisions, same-route retries, + fallbacks, waiting sessions, circuit opens and closes, half-open probes, + continuation creation, restart reconciliation, stale-generation drops, and + utility routing outcomes. +- The shared router preserves `routing_route_attempts_total` and + `routing_fallback_total`. It retires `routing_provider_degraded_total`, + `routing_provider_recovered_total`, and `routing_parked_runs_total`. Their + replacements are `routing_resource_circuit_opened_total`, + `routing_resource_circuit_closed_total`, and + `routing_waiting_sessions_total`. Structured log names use the same semantic + mapping. `AGENTS.md` documents the new names. + +## Test strategy + +- Backend table-driven tests cover profile-kind resolution, fixed ordering, + class-policy validation and evaluation, reset waits, exponential retry, + exhausted outcomes, circuit sharing and scoping, generation fencing, + continuation, restart reconciliation, utility calls, and the + concrete/dynamic transparent resolver boundary. +- Repository integration tests cover dynamic configuration, logical route + state, immutable turn and utility attribution, conflict details, and + preservation of legacy Office routing rows. +- Desktop and mobile Playwright tests cover profile creation and ordering, + workflow and utility selection, one-tab provider switching, localized route + rows, immutable provider/model badges, capability replacement, waiting and + recovery actions, and Office execution-profile selection. Phone editors use + direct navigation and a single internal scroll owner. Temporary candidate + choices use the existing inset picker drawer. + +## State machine + +| State | Trigger | Next state | +| --- | --- | --- | +| `selecting` | route generation and candidate committed | `starting` | +| `selecting` | no eligible candidate, circuit recovery exists | `waiting` | +| `selecting` | no candidate and user action required | `action_required` | +| `starting` | downstream ACP initialized and capabilities captured | `active` | +| `starting` | safe classified failure with reset wait | `waiting_for_reset` | +| `starting` | safe classified failure with retry budget | `retry_wait` | +| `starting` | recovery exhausted | `switching` or `action_required` | +| `active` | safe classified failure with reset wait | `waiting_for_reset` | +| `active` | safe classified failure with retry budget | `retry_wait` | +| `active` | recovery exhausted | `switching` or `action_required` | +| `active` | active candidate disabled or removed | `reroute_pending` | +| `active` | concrete-profile process restart | `starting` with same route | +| `reroute_pending` | active turn settles | `switching` | +| `switching` | successor decision committed | `starting` with generation + 1 | +| `switching` | no successor is eligible | `waiting` or `action_required` | +| `retry_wait` or `waiting_for_reset` | deadline due and generation current | `retrying` | +| `retrying` | candidate succeeds | `active` | +| `retrying` | safe classified failure | class policy evaluates again | +| `retry_wait` or `waiting_for_reset` | Skip now | `switching` | +| `retry_wait` or `waiting_for_reset` | Cancel wait or Stop | `action_required` or `stopped` | +| `waiting` | reset, probe success, or Retry now | `selecting` | +| `action_required` | settings or credentials change, or explicit retry | `selecting` | +| any nonterminal state | user stops session | `stopped` | + +Only one transition owner can commit a new route generation. Candidate +selection is not an exclusive lease across sessions. Late ACP frames and user +actions are ignored when their generation no longer matches. Probe results also +require the current exclusive probe lease. + +## Permissions + +- Users who can manage agent profiles can create and edit dynamic profiles. +- Users who can select an agent profile can select a dynamic profile but cannot + thereby view candidate credentials. +- Office agents cannot edit routing policy unless a separate existing profile + management permission authorizes it. Assigning tasks does not grant that + permission. + +## Failure modes + +- Invalid or deleted candidate references make that candidate ineligible and + surface a profile configuration error. They never fall through to a workspace + default profile. +- If route-state persistence fails, Kandev does not launch the successor. This + prevents an invisible provider switch. +- If a provider fails after assistant or tool effects, the continuation package + marks delivery as potentially effectful and requires the successor to inspect + durable state before repeating work. +- If capability discovery for the new provider fails, the route does not become + `active`. The UI retains the prior controls as disabled until another route is + selected or the failure is resolved. +- If multiple tasks fail the same resource concurrently, the first committed + circuit transition wins. Other failures extend evidence without starting + duplicate probes. +- If every route is open, tasks wait under the same logical profile and expose + the earliest known recovery time and remediation actions. +- If a route action carries a stale generation, Kandev does not change route + state and returns the authoritative route snapshot. +- If an error is unclassified, stale, conflicting, or effect-unsafe, Kandev + does not apply its candidate class policy and enters manual recovery. +- If a trusted reset is beyond the configured maximum wait, Kandev does not + wait for or shorten it. It proceeds to retry or the exhausted outcome. +- If dynamic routing is disabled after a session is persisted, Kandev keeps its + route state and does not launch or resume downstream ACP until the feature is + enabled again. +- If the backend restarts during `switching`, it reconciles the persisted route + generation and downstream ownership before launching. An ambiguous launch is + not duplicated. + +## Persistence guarantees + +- Dynamic profile configuration and reference protection survive backend + restart and workspace reload. +- Logical task-session identity, route generation, active concrete profile, + provider-native session ownership, classification and policy snapshots, + retry ordinals, waiting deadlines, pending outcomes, and shared circuits are + durable. +- Provider-native resume data is usable only with the concrete profile that + created it. +- A backend restart resumes the same downstream session when supported and safe, + otherwise it starts a fresh session on the same route with a continuation + package. It does not silently re-run route selection unless the persisted + route is no longer eligible. +- Route attempt history is append-only for audit. Profile edits affect future + selections and do not rewrite recorded decisions. +- Turn-level execution profile, provider, model, and route-generation badges are + immutable and survive reload even when the current route or profile changes. + +## Scenarios + +- **GIVEN** a Kanban task assigned to a dynamic Frontier profile with Fable then + Codex, **WHEN** Fable reports a high-confidence quota failure, **THEN** the + same task tab shows a route-change row, starts a fresh Codex ACP session with a + continuation package, and replaces the model/options/commands controls. +- **GIVEN** an Office CEO that selects the same dynamic profile, **WHEN** the + same failure occurs, **THEN** the shared router performs the same transition + while the CEO identity, instructions, skills, permissions, budget, task, and + worktree remain unchanged. +- **GIVEN** a task, workflow, Office agent, or utility caller selects a dynamic + profile ID, **WHEN** it starts execution, **THEN** the caller uses its normal + profile path and the shared resolver starts the conductor without requiring a + concrete execution profile from that caller. +- **GIVEN** a utility agent selects a dynamic profile, **WHEN** its first + candidate fails with a classified quota error before returning a result, + **THEN** the invocation tries the next configured candidate and records both + the logical and successful concrete profile IDs without creating a task tab. +- **GIVEN** two tasks use profiles with the same proven Fable credential + binding, **WHEN** one opens its quota circuit, **THEN** the second skips Fable + without making a failing launch. +- **GIVEN** another profile uses a different or unknown Fable credential + binding, **WHEN** its resource key is evaluated, **THEN** the first binding's + quota circuit does not disable it. +- **GIVEN** a healthy session on Fable, **WHEN** its dynamic profile is reordered + or receives an error-action edit, **THEN** it stays on Fable until the next + route decision and that decision uses the new profile version. +- **GIVEN** the active Fable candidate is removed or disabled, **WHEN** the + current turn finishes, **THEN** the session reroutes at that safe boundary even + when no provider error occurred. +- **GIVEN** a Dynamic Frontier chat switches from Fable to Codex, **WHEN** the + user reloads it, **THEN** the tab and message authors still show Dynamic + Frontier, each assistant turn shows its original provider/model badge, the + durable switch row remains in sequence, and controls describe Codex. +- **GIVEN** an open route reaches its retry time while many tasks are waiting, + **WHEN** recovery begins, **THEN** one half-open probe runs and the other tasks + do not stampede the provider. +- **GIVEN** an earlier candidate recovers while a task is healthy on fallback, + **WHEN** the circuit closes, **THEN** that task stays on fallback and future + route selections use the configured candidate order. +- **GIVEN** the backend restarts with a dynamic session active, **WHEN** runtime + reconciliation completes, **THEN** the same Kandev session tab returns and the + backend resumes only the provider-native session owned by the persisted + concrete profile. +- **GIVEN** an existing database contains legacy Office workspace routing rows, + **WHEN** it upgrades to dynamic routing, **THEN** those rows remain unchanged + but no Office settings or launch path reads or displays them. +- **GIVEN** a waiting route at generation 7, **WHEN** the user sends Skip now + for generation 7, **THEN** Kandev excludes the current candidate for one + decision and returns the resulting authoritative route snapshot. +- **GIVEN** a route has advanced to generation 8, **WHEN** a delayed Retry now + for generation 7 arrives, **THEN** Kandev rejects it and returns generation + 8 without changing route state. +- **GIVEN** a transient policy allows three retries starting at five seconds, + **WHEN** the candidate keeps returning an effect-safe capacity error, + **THEN** the route persists 5, 10, and 20 second waits before applying its + configured skip or stop outcome. +- **GIVEN** a hard quota error includes a trusted reset in one minute, **WHEN** + the policy permits reset waits up to five minutes, **THEN** the route waits + durably and retries the same candidate after the reset. +- **GIVEN** a new dynamic profile is being created, **WHEN** its editor opens, + **THEN** it contains one draft profile and no Add profile action. +- **GIVEN** Office is enabled and dynamic routing is disabled, **WHEN** one + Office identity selects a concrete profile and another selects a dynamic + profile, **THEN** the concrete identity can launch and the dynamic identity + enters an actionable feature-disabled state. +- **GIVEN** a phone viewport, **WHEN** the user creates or inspects a dynamic + profile, **THEN** candidates and class policies use a single-column touch + layout. Routing status and recovery actions remain available without + horizontal page overflow. + +## Out of scope + +- Lossless transfer of hidden provider-native conversation state. +- Nested dynamic profiles or arbitrary routing graphs in the first version. +- Session-cost polling, subscription-usage polling, telemetry-backed routing, + cost preference, allowance thresholds, and `interrupt_turn`. These behaviors + belong to [Dynamic Agent Telemetry Routing](dynamic-agent-telemetry-routing.md). +- Automatic purchase, plan upgrade, or credential refresh. +- Model-based classification or extraction of provider error details. +- Moving a healthy active task back to an earlier candidate immediately after + recovery. +- Letting workflows or Office workspaces duplicate provider order, error + actions, or health policy outside a dynamic profile. +- Automatically converting legacy Office routing rows into dynamic profiles or + deleting those rows during this feature's rollout. diff --git a/docs/specs/agents/utility-agent-profiles.md b/docs/specs/agents/utility-agent-profiles.md index 039a6bea5a..330000cfd8 100644 --- a/docs/specs/agents/utility-agent-profiles.md +++ b/docs/specs/agents/utility-agent-profiles.md @@ -25,23 +25,27 @@ permission choice that its caller cannot answer. ## What -- Settings > Utility Agents has one **Default utility agent profile** selection. The choice is a - concrete global agent profile, not an agent family/model pair. -- Each built-in utility action either inherits the default utility profile or selects one concrete +- Settings > Utility Agents has one **Default utility agent profile** selection. The choice is an + eligible concrete or dynamic global agent profile, not an agent family/model pair. +- Each built-in utility action either inherits the default utility profile or selects one eligible profile as an override. -- Every custom utility agent selects one concrete profile. A custom utility agent cannot be created - or saved without a profile. -- A built-in utility action without a concrete profile override inherits the default utility - profile. A stale concrete override remains **unconfigured** after its profile is deleted or +- Every custom utility agent selects one eligible concrete or dynamic profile. A custom utility + agent cannot be created or saved without a profile. +- A built-in utility action without a profile override inherits the default utility + profile. A stale override remains **unconfigured** after its profile is deleted or disabled and cannot run until the user repairs it. - An empty `unconfigured` built-in binding is normalized to `inherit`. Selecting Default in an action picker persists the same inherited state and never submits an empty explicit binding. -- Eligible choices are enabled, non-deleted, global profiles for ACP inference-capable agents. - CLI-passthrough profiles and workspace-scoped Office profiles are not eligible. +- Eligible choices are enabled, non-deleted, global concrete profiles for ACP inference-capable + agents and global dynamic profiles with at least one valid candidate. CLI-passthrough profiles and + workspace-scoped Office profiles are not eligible. - A utility invocation resolves its effective profile at the start of the call. It uses that profile's agent, model, mode, dynamic config options, enabled CLI flags, command prefix, environment/secret references, and permission policy. Editing the profile affects the next call; an in-flight call keeps the configuration resolved when it started. +- A dynamic selection resolves through the shared dynamic conductor. The caller submits the same + profile ID as for a concrete selection. The call record retains that logical profile ID and the + concrete execution profile that produced the final result. - The UI shows profile names and their parent agent names. It does not expose separate utility-only model or permission controls. - Built-in action overrides and the default profile participate in the shared Settings save/discard @@ -63,14 +67,14 @@ permission choice that its caller cannot answer. | Field | Type | Constraint | Meaning | | ------------------ | ------ | ---------------------------- | ------------------------------------------------------------------------------ | -| `agent_profile_id` | string | empty for `inherit`, required for `explicit`, retained for stale `unconfigured` bindings | Concrete profile reference. | +| `agent_profile_id` | string | empty for `inherit`, required for `explicit`, retained for stale `unconfigured` bindings | Concrete or dynamic logical profile reference. | | `profile_binding_state` | enum | `inherit`, `explicit`, or `unconfigured` | Whether the row inherits the default, names a profile, or needs repair. | `inherit` is valid only for built-in rows. `explicit` requires an eligible profile. `unconfigured` -is used for a custom row without a profile and for a built-in row whose concrete profile binding is +is used for a custom row without a profile and for a built-in row whose profile binding is stale or unavailable. Only a built-in row whose persisted state is `inherit` uses the selected default. A built-in row with an empty profile ID in either the legacy `explicit` or `unconfigured` -state is normalized to `inherit`. A concrete stale profile ID remains `unconfigured` so the user can +state is normalized to `inherit`. A stale profile ID remains `unconfigured` so the user can repair or replace the known override explicitly. The legacy `agent_id` and `model` columns may remain temporarily as migration inputs, but they are @@ -80,8 +84,10 @@ Portable user settings store `default_utility_agent_profile_id: string`. Empty m configured. The legacy `default_utility_agent_id` and `default_utility_model` values are migration inputs only. -`utility_agent_calls` stores the effective `agent_profile_id` in addition to the already-recorded -resolved model. Historical calls created before this field exists have an empty profile ID. +`utility_agent_calls` stores the effective logical `agent_profile_id`, concrete +`execution_profile_id`, and resolved model. For a concrete selection the two +profile IDs match. For a dynamic selection they differ. Historical calls +created before either field exists keep an empty value. ### Legacy migration @@ -92,10 +98,10 @@ non-deleted, non-passthrough global profiles whose parent agent matches the lega and whose configured model matches the legacy model (including an explicit empty model). Exactly one match is copied to `agent_profile_id` and sets the row state to `explicit`. -Zero matches or multiple matches set a custom row to `unconfigured`. A built-in row with no concrete +Zero matches or multiple matches set a custom row to `unconfigured`. A built-in row with no matched profile ID becomes `inherit`, including an empty `unconfigured` row written by an older release. The backend does not pick the first profile, infer from a display name, or silently use a provider -default. Concrete stale built-in IDs remain `unconfigured`. Custom utility agents that cannot be +default. Stale built-in IDs remain `unconfigured`. Custom utility agents that cannot be migrated remain stored and editable but are not executable until the user selects a profile. The legacy values remain available to a later retry or diagnostic report, but the new state is authoritative after the migration. @@ -111,7 +117,7 @@ authoritative after the migration. - User-settings read and patch payloads expose `default_utility_agent_profile_id`. - `POST /api/v1/utility/execute` keeps its request shape: callers select a utility-agent ID, and the backend resolves the effective profile. Successful responses and call-history responses keep the - resolved model and add the effective profile ID to call history. + resolved model and expose logical and concrete profile attribution in call history. - Plugin `Host.InvokeUtilityAgent` and its plugin manifest selector keep selecting a utility-agent ID; there is no plugin-facing profile-ID field. @@ -132,10 +138,11 @@ authoritative after the migration. - With no effective profile, invocation fails before a call is dispatched and tells the user to select a profile in Settings > Utility Agents. -- A missing, deleted, disabled, CLI-passthrough, workspace-scoped, or non-inference-capable - concrete profile binding fails closed. An empty built-in binding is normalized to `inherit` and - uses the saved global default. The backend never falls back to another concrete profile, the active - task profile, a raw agent/model pair, or the first available agent. +- A missing, deleted, disabled, CLI-passthrough, workspace-scoped, or otherwise ineligible profile + binding fails closed. An empty built-in binding is normalized to `inherit` and uses the saved + global default. A concrete selection never falls back to another profile. A dynamic selection may + select another configured candidate under its routing policy, but never the active task profile, + a raw agent/model pair, or the first available agent. - A profile launch-policy error (invalid command prefix, unresolved required secret, invalid config option, or unavailable agent runtime) is surfaced as the utility call failure. The runner does not retry without that setting. @@ -159,8 +166,8 @@ authoritative after the migration. default repairs them without editing each action. - Built-in rows left as empty `unconfigured` by an older release are repaired idempotently to `inherit`. Selecting Default in the action picker also persists `inherit` and survives reload. -- Utility call history retains the effective profile ID and resolved model even if the profile is - later edited or deleted. +- Utility call history retains the logical profile ID, concrete execution profile ID, and resolved + model even if either profile is later edited or deleted. ## Scenarios @@ -175,6 +182,10 @@ authoritative after the migration. changed, **THEN** that action continues to use its override. - **GIVEN** a custom utility agent, **WHEN** the user opens its create/edit dialog, **THEN** one profile picker replaces the separate agent and model controls and saving is gated on a profile. +- **GIVEN** a utility agent selects a dynamic profile, **WHEN** its first candidate fails with a + classified quota error before returning a result, **THEN** the shared conductor tries the next + configured candidate and call history retains the dynamic logical profile plus the successful + concrete execution profile. - **GIVEN** a selected profile with auto-approval enabled, **WHEN** its agent emits an ACP permission request, **THEN** the utility call approves it using the profile policy and continues without user interaction. diff --git a/docs/specs/office/agents.md b/docs/specs/office/agents.md index e90bd1204f..aec67f34c3 100644 --- a/docs/specs/office/agents.md +++ b/docs/specs/office/agents.md @@ -10,14 +10,14 @@ owner: cfl Kandev has execution profiles (configuration templates for a concrete CLI, account, model, flags, environment, and MCP setup) but also needs persistent, stateful Office agents. Without a stable Office identity, switching a provider also risks switching or copying the agent's role, instructions, skills, permissions, budget, and history. -Office therefore treats a workspace-scoped rich `agent_profiles` row as a stable Office identity and selects a separate concrete execution profile for each launch. Both row types remain in the unified table established by ADR 0005, but they have different responsibilities. Office agents run inside a narrow capability-scoped runtime, call kandev through a structured CLI rather than raw curl, and expose per-agent dashboards with run history, costs, and per-run detail pages. +Office therefore treats a workspace-scoped rich `agent_profiles` row as a stable Office identity and selects a separate execution agent profile. The selected profile can be concrete or dynamic. A dynamic profile resolves a concrete profile for each launch through the shared routing mechanism used by Kanban. These profile kinds remain in the unified table established by ADR 0005, but they have different responsibilities. Office agents run inside a narrow capability-scoped runtime, call kandev through a structured CLI rather than raw curl, and expose per-agent dashboards with run history, costs, and per-run detail pages. ## What ### Agent instances - An Office agent is a persistent `agent_profiles` row scoped by `workspace_id`; its row ID is the logical `agent_profile_id` referenced by assignments, instructions, skills, budgets, permissions, and Office history. -- A launch separately resolves an `execution_profile_id` from the agent's effective tier and provider order. The referenced execution profile owns the concrete CLI runtime configuration. +- An Office agent selects an execution agent profile. A concrete selection launches directly. A dynamic selection resolves an `execution_profile_id` from the dynamic profile; the resolved concrete profile owns the CLI runtime configuration. - The Office identity owns: - **Name**: human-readable label ("CEO", "Frontend Worker", "QA Bot"). - **Role**: `ceo`, `worker`, `specialist`, `assistant`, or `reviewer`. Determines default permissions and UI treatment. @@ -30,8 +30,8 @@ Office therefore treats a workspace-scoped rich `agent_profiles` row as a stable - **Executor preference**: optional executor override for this agent. - **Channels**: optional external messaging channels (Telegram, Slack). - Multiple Office agents can use the same execution profile without sharing Office instructions, skills, permissions, budgets, or history. -- Changing an agent's tier or provider order changes future execution-profile resolution without changing the Office identity. -- Provider routing and failover are specified in [routing](./routing.md). +- Changing an agent's selected execution profile changes future resolution without changing the Office identity. +- Provider order, mappings, fallback, and shared health belong to [Dynamic Agent Routing](../agents/dynamic-agent-routing.md), not to Office settings. ### Hierarchy @@ -82,6 +82,7 @@ id: "abc-123" name: CEO role: ceo agent_profile_id: "prof_abc123" +execution_agent_profile_id: "dyn_frontier" reports_to: "" icon: crown permissions: '{"can_create_tasks":true,"can_assign_tasks":true,"can_create_agents":true}' @@ -91,6 +92,16 @@ desired_skills: '["memory","delegation-playbook"]' executor_preference: "" ``` +`agent_profile_id` identifies the Office agent itself. +`execution_agent_profile_id` references the concrete or dynamic profile used to +run it. + +The workspace-scoped Office identity row persists this selection in +`agent_profiles.execution_agent_profile_id`, an empty-by-default soft reference +to another global or compatible same-workspace profile. It is not a cascading +foreign key because confirmed profile deletion retains stale bindings for +explicit repair. A missing or disabled selection makes launches fail closed. + ### Agent runtime (DB) `office_agent_runtime` row per agent instance. @@ -105,17 +116,25 @@ office_agent_runtime Runtime state must survive restarts (a budget-paused agent stays paused). Not user-editable, not exported. On startup, the reconciliation service merges filesystem config with this DB state: missing runtime rows are created with `status=idle`; orphaned rows (no YAML) are deleted. -### Office identities and execution profiles +### Office identities and execution agent profiles Office identities and execution profiles stay in the existing `agent_profiles` DB table, as required by ADR 0005, but launch resolution does not treat them as the same logical object: - A workspace-scoped rich row is the Office identity. Its `agent_profile_id` remains stable across provider changes and is used for hierarchy, instructions, skills, Office permissions, budgets, costs, and task/run ownership. -- A concrete execution profile is selected per launch and recorded as `execution_profile_id`. It owns the registered CLI/provider, credentials and environment, model, mode, ACP config options, CLI flags, CLI permission behavior, passthrough mode, and MCP configuration. -- Global shallow profiles are the normal execution-profile choices. Existing same-workspace rows may remain selectable for upgrade compatibility when they carry a valid CLI configuration. -- An execution profile from another workspace cannot be selected. -- Non-Office launches remain compatible: when no distinct execution profile is supplied, runtime treats `execution_profile_id = agent_profile_id`. +- The Office identity references one execution agent profile. A concrete profile launches directly. A dynamic profile owns reusable routing policy and resolves a concrete profile per launch. +- The resolved concrete profile is recorded as `execution_profile_id`. It owns the registered CLI/provider, credentials and environment, model, mode, ACP config options, CLI flags, CLI permission behavior, passthrough mode, and MCP configuration. +- Global shallow concrete and dynamic profiles are the normal execution-agent choices. Existing same-workspace rows may remain selectable for upgrade compatibility when valid. +- An execution agent profile from another workspace cannot be selected. +- Profile callers pass one selected profile ID to the shared execution resolver. + For a concrete selection, the resolved `execution_profile_id` equals that ID. + For a dynamic selection, the logical selected profile remains dynamic while + the conductor records the concrete candidate as `execution_profile_id`. + Callers do not resolve candidates or branch on profile kind. +- The profile `kind` describes the execution family. The Office identity role + is separate. An Office identity can bind to a concrete or dynamic execution + profile, but a rich Office identity cannot appear in a dynamic candidate list. -The CLI-shaped columns on rich Office rows remain in the schema for compatibility with ADR 0005 and existing data, but they are not authoritative for routed Office launches. New Office configuration changes select tiers and provider order instead of copying CLI runtime fields into the identity row. +The CLI-shaped columns on rich Office rows remain in the schema for compatibility with ADR 0005 and existing data, but they are not authoritative for Office launches. New Office configuration selects a concrete or dynamic execution agent profile instead of copying CLI runtime fields or routing rules into the identity row. ### Instructions diff --git a/docs/specs/office/dashboard.md b/docs/specs/office/dashboard.md index 08c30fecb8..b2463812ad 100644 --- a/docs/specs/office/dashboard.md +++ b/docs/specs/office/dashboard.md @@ -171,7 +171,7 @@ The dashboard is a read-mostly surface scoped to a single workspace. Authorizati - `POST /inbox/dismiss`: requires the `MarkFixedHandler` to be wired (`503` otherwise) and resolves under `user_id = "default"`. - Settings mutation (`PATCH /workspaces/:wsId/settings`): no gate today; UI-only. - **Activity feed entries** carry `actor_type ∈ {user, agent, system}` and `actor_id` (user id, agent instance id, or literal `"system"`). The feed does not redact entries per viewer — every workspace member sees the full log. -- **Routing endpoints** (`/workspaces/:wsId/routing*`, `/agents/:id/route`, `/runs/:id/attempts`) are scoped the same way; their authorization rules live in `office/routing.md`. +- **Route status** is read from the shared dynamic-session route API and is scoped through the Office task/run. Office does not expose routing-policy mutation endpoints; those permissions belong to agent profile settings as specified in [Dynamic Agent Routing](../agents/dynamic-agent-routing.md). See `agents/` spec for the role → permission defaults and the no-escalation rule that governs the underlying permission keys. diff --git a/docs/specs/office/overview.md b/docs/specs/office/overview.md index a35480cc34..61d120663c 100644 --- a/docs/specs/office/overview.md +++ b/docs/specs/office/overview.md @@ -132,13 +132,14 @@ Filesystem layout: **Skill injection**: skills for agent sessions are written into the agent's worktree (CWD) before each session. Skill content can come from the DB (inline skills created via UI), the filesystem (imported from GitHub/skills.sh), or bundled (shipped with the kandev binary). All routes inject into the agent-specific skill path under the worktree. See [office agents](./agents.md#skill-injection). -**Office identities and execution profiles** share the existing kandev `agent_profiles` DB table but have separate responsibilities. The workspace-scoped rich row owns Office identity and metadata; provider routing references a concrete execution profile for each launch instead of copying runtime configuration into the Office row. Filesystem export format: +**Office identities and execution agent profiles** share the existing kandev `agent_profiles` DB table but have separate responsibilities. The workspace-scoped rich row owns Office identity and metadata. It selects a concrete or dynamic execution agent profile. A dynamic profile uses the shared routing mechanism to resolve a concrete launch profile instead of copying runtime configuration or routing rules into the Office row. Filesystem export format: ```yaml # agents/ceo.yml name: CEO role: ceo agent_profile_id: "prof_abc123" +execution_agent_profile_id: "dyn_frontier" desired_skills: [memory, delegation-playbook] ``` @@ -161,8 +162,8 @@ When a user opens `/office`, the backend checks both DB and filesystem state via **Onboarding wizard** is a full-page (not modal) 5-step flow: 1. **Welcome + Workspace** - workspace name (default "Default Workspace") and task prefix (default "KAN", explained as "Tasks will be numbered KAN-1, KAN-2, etc."). -2. **Tier Agent Profiles** - Frontier / Balanced / Economy profile selectors with hover help explaining where each tier family is used when the coordinator creates or schedules agents. -3. **Create CEO Agent** - agent name (default "CEO"), coordinator agent profile dropdown (Claude, Codex, etc.), executor preference (Local / Docker / Sprites) with descriptions, and coordinator tier selector defaulting to Frontier. +2. **Execution Agent Profile** - select an existing concrete or dynamic profile for the CEO, with a link to create a dynamic profile in Agent settings. +3. **Create CEO Agent** - agent name (default "CEO"), a read-only confirmation of the Step 2 execution-profile selection, and executor preference (Local / Docker / Sprites) with descriptions. The onboarding API persists the single profile selected in Step 2. 4. **First Task** - editable starter task prefilled with title "Setup Workspace" and a CEO brief that asks the agent to inspect `https://github.com/org/repo` (replaceable by the user), create one project per repository, create the needed agent team, give agents responsibilities and permissions, then propose follow-up tasks/subtasks for human approval before creating them. `[Back] [Skip] [Next]`; Skip clears the starter task. 5. **Review & Launch** - summary card of what will be created. `[Back] [Create & Launch]`. @@ -170,7 +171,7 @@ When a user opens `/office`, the backend checks both DB and filesystem state via 1. Office workspace: `kandev.yml` on filesystem + DB row in `workspaces` + system office workflow (7 steps). 2. CEO Office identity with `role=ceo`, full Office permissions, and bundled skills (kandev-protocol, memory, kandev-projects). 3. Agent runtime row `status=idle` in `office_agent_runtime`. -4. Workspace provider routing seed: automatic fallback disabled by default, default tier persisted from the coordinator tier selector, and Frontier / Balanced / Economy mapped to authoritative execution profile IDs so launches use the complete selected CLI configuration and profile deletion is blocked while referenced. +4. CEO execution-profile binding: the selected concrete or dynamic profile is persisted. Office does not create workspace provider mappings or routing rules. Legacy routing rows, when present from an older build, remain stored but inactive and hidden. 5. First task if not skipped: assigned to the CEO, `status=todo`; a `task_assigned` wakeup is enqueued so the scheduler picks it up. The task brief tells the CEO to create the required projects, including one per repository, and propose follow-up tasks for human approval before creating them. 6. Onboarding state marked completed in the DB. @@ -331,7 +332,7 @@ The CEO and workers move tasks between states by calling the same API the UI use - **GIVEN** a YAML file with parse errors, **WHEN** the user tries to import, **THEN** the import preview shows the parse error for that file and other files can still be imported. -- **GIVEN** a new user opening `/office` for the first time, **WHEN** no workspace exists on DB or FS, **THEN** they are redirected to `/office/setup` and see the 4-step wizard. +- **GIVEN** a new user opening `/office` for the first time, **WHEN** no workspace exists on DB or FS, **THEN** they are redirected to `/office/setup` and see the 5-step wizard. - **GIVEN** a user opening `/office` for the first time, **WHEN** no DB workspace exists but FS workspaces are found, **THEN** they are redirected to `/office/setup` and see the import prompt with workspace names listed. @@ -341,7 +342,7 @@ The CEO and workers move tasks between states by calling the same API the UI use - **GIVEN** a user on the review step who clicks "Create & Launch" with the default first task still present, **WHEN** all inputs are valid, **THEN** the workspace, CEO agent, and setup task are created, a `task_assigned` wakeup is enqueued, and the dashboard shows 1 agent enabled and 1 task in progress. -- **GIVEN** a user who skipped the first task on step 3, **WHEN** they reach the dashboard, **THEN** the CEO agent exists but is idle (no tasks) and the empty state says "Assign a task to your CEO to get started." +- **GIVEN** a user who skipped the first task on step 4, **WHEN** they reach the dashboard, **THEN** the CEO agent exists but is idle (no tasks) and the empty state says "Assign a task to your CEO to get started." - **GIVEN** a returning user who already completed onboarding, **WHEN** they open `/office`, **THEN** they see the dashboard directly. diff --git a/docs/specs/office/routing.md b/docs/specs/office/routing.md index c1e3781188..e1ffe1ee7c 100644 --- a/docs/specs/office/routing.md +++ b/docs/specs/office/routing.md @@ -1,11 +1,22 @@ --- -status: draft +status: archived created: 2026-05-10 owner: cfl --- # Office Provider Routing +> Superseded by [Dynamic Agent Routing](../agents/dynamic-agent-routing.md). +> Office now selects a concrete or dynamic execution agent profile. Provider +> mappings, rules, health, and fallback belong to the reusable dynamic profile, +> not to Office workspace settings. This file is retained as the historical +> Office-only contract until its implementation is migrated. +> +> Upgrade compatibility retains the legacy Office routing tables and rows but +> no longer displays or reads them for launch decisions. They are not converted +> automatically. Users configure concrete or dynamic execution profiles for new +> behavior. + Provider routing lets a workspace map abstract model tiers (Frontier / Balanced / Economy) to concrete execution profiles, configure an ordered provider fallback chain, and degrade routes automatically when they hit auth / quota / rate / outage limits. An execution profile is a complete launch configuration, not a model-only overlay. This spec describes the routing contract: Office identity preservation, execution-profile selection, fallback, provider health, degraded-state lifecycle, and wake-reason tier policy. ## Why diff --git a/docs/specs/office/runtime.md b/docs/specs/office/runtime.md index 87bfafaa54..e2105afa8b 100644 --- a/docs/specs/office/runtime.md +++ b/docs/specs/office/runtime.md @@ -6,6 +6,15 @@ owner: cfl # Office Agent Runtime — Error Handling Contract +> **Amendment (2026-08-17):** The provider-classification and automatic +> recovery policy in this spec is superseded by +> [Provider Error Recovery](../platform/provider-error-recovery.md) and +> [Dynamic Agent Routing](../agents/dynamic-agent-routing.md). Office consumes +> the shared transient/hard catalogue and the selected dynamic profile's policy. +> The terminal run state, inbox, and manual recovery contracts below still own +> non-provider and effect-unsafe failures. The historical v1 classification +> text remains as migration context and is not the target behavior. + This spec defines the runtime contract for how Office agent errors are observed, classified, and recovered. It covers what the lifecycle layer publishes on failure, how sessions and wakeups transition, how failures surface in the inbox and per-task chat, and how the user recovers — via Mark fixed, Resume session, or reassignment to a different agent. ## Why @@ -260,7 +269,7 @@ Recovery and runtime-action authorization splits across three actors: human user - **`affected_tasks` snapshot on `agent_paused_after_failures`**: NOT recomputed when listed tasks are reassigned away. The snapshot is intentionally point-in-time so the user resolves the pause entry as a whole. - **`scheduled_retry` runs**: not produced in v1. Every adapter error is terminal; the wakeup is stamped `failed` with no follow-up scheduled. The `scheduled_retry` status is reserved for a future classifier. -See also: [`office/routing.md`](routing.md) for the provider-routing error codes consumed at launch / dispatch time, and [`office/scheduler.md`](scheduler.md) for wakeup queue semantics and the staleness check that cancels superseded wakeups. +See also: [Dynamic Agent Routing](../agents/dynamic-agent-routing.md) for provider routing and [`office/scheduler.md`](scheduler.md) for wakeup queue semantics and the staleness check that cancels superseded wakeups. ## Scenarios diff --git a/docs/specs/office/scheduler.md b/docs/specs/office/scheduler.md index e778d618f8..cf3ada306c 100644 --- a/docs/specs/office/scheduler.md +++ b/docs/specs/office/scheduler.md @@ -285,9 +285,10 @@ processWakeup: 6. Budget pre-check -- workspace -> project -> agent budgets; pause_agent action skips 7. Build context -- assemble prompt from source, payload, context_snapshot 8. Resolve executor -- task override -> agent preference -> project -> workspace default - 9. Create session -- TaskSession through the orchestrator pipeline; per-task-and-agent session - 10. Launch -- lifecycle manager -> executor backend -> agentctl -> agent subprocess - 11. Finish -- mark wakeup finished; parse output for follow-up actions + 9. Resolve execution agent -- Office binding -> concrete profile or shared dynamic router + 10. Create session -- one logical TaskSession through the orchestrator pipeline + 11. Launch -- lifecycle -> compute executor -> dynamic conductor or concrete agentctl + 12. Finish -- mark wakeup finished; parse output for follow-up actions ``` ### Atomic task checkout diff --git a/docs/specs/platform/provider-error-recovery.md b/docs/specs/platform/provider-error-recovery.md index fe80df9367..7515adb461 100644 --- a/docs/specs/platform/provider-error-recovery.md +++ b/docs/specs/platform/provider-error-recovery.md @@ -1,6 +1,7 @@ --- status: draft created: 2026-08-08 +updated: 2026-08-17 owner: Kandev --- @@ -9,355 +10,337 @@ owner: Kandev Decisions: - [ADR-2026-08-08-provider-neutral-agent-error-recovery](../../decisions/2026-08-08-provider-neutral-agent-error-recovery.md) -- [ADR-2026-07-29-agent-stall-user-controlled-recovery](../../decisions/2026-07-29-agent-stall-user-controlled-recovery.md) +- [ADR-2026-08-17-provider-error-classes-and-policies](../../decisions/2026-08-17-provider-error-classes-and-policies.md) +- [ADR-2026-08-13-dynamic-agent-profile-routing](../../decisions/2026-08-13-dynamic-agent-profile-routing.md) + +Implementation plan: +[Provider Error Policies](../../plans/provider-error-policies/plan.md). + +Dynamic profile configuration and route continuity are specified in +[Dynamic Agent Routing](../agents/dynamic-agent-routing.md). This specification +owns the provider-neutral error catalogue, policy schema, and recovery +semantics used by concrete profiles, dynamic profiles, Kanban, utility calls, +and Office. ## Why -Codex, Claude, OpenCode, and future agent CLIs report equivalent provider -failures through different ACP frames and diagnostic streams. Users need -temporary capacity failures to recover with honest progress. Kanban sessions -need conservative interactive retry, while unattended Office runs need durable -configured fallback and scheduler recovery. Both must classify the provider -failure consistently without sharing the same retry policy. +Agent CLIs and providers report equivalent failures through different ACP +frames, HTTP metadata, process exits, and diagnostic strings. Capacity, +network, subscription, and quota failures need different recovery behavior, +but orchestration code must not branch on provider names or raw prose. + +Users also need explicit control over how a dynamic candidate reacts. A short +outage may justify a few retries, while an exhausted subscription may justify +waiting for a near reset, skipping to the next candidate, or stopping for +manual action. The same classification and policy semantics must apply in +Kanban and Office so a selected dynamic profile does not change meaning between +workspace modes. ## What -### Provider-neutral classification +### Evidence and deterministic classification -- Kandev correlates supported error evidence with the active agent session, - foreground prompt, and prompt generation before it can settle a turn. -- Evidence may come from an ACP prompt error, ordered ACP updates and completion, - managed structured stderr, a process exit, or structured HTTP metadata. -- Agent-specific evidence adapters produce one bounded, sanitized evidence - envelope. Orchestration, lifecycle, and UI code do not inspect an agent name or - raw provider message to choose recovery behavior. -- A shared classifier assigns only a stable semantic code, confidence, scope, - classifier rule ID, and validated timing hints. It does not assign a global - retry or fallback verdict. -- The classifier distinguishes temporary model capacity from an invalid or - unavailable model, and short rate throttling from subscription or plan quota - exhaustion. -- An explicit execution context selects either Kanban interactive recovery or - Office unattended routing. Only high-confidence, current-prompt evidence can - authorize Kanban automatic retry or Office post-start fallback. Inactivity - alone never classifies a provider failure. -- Classifier rules are deterministic and fixture-driven. Adding another known - error example normally adds a sanitized fixture and registry rule rather than - an orchestrator, UI, or agent-type branch. +- Agent adapters collect bounded evidence from structured ACP errors, ordered + ACP updates, managed stderr, process exits, and structured HTTP metadata. +- Evidence is correlated to the active invocation, prompt generation, and + lifecycle phase before it can authorize recovery. +- A shared deterministic classifier maps evidence to a stable semantic code, + policy class, confidence, scope, classifier rule ID, and validated timing + hints. +- Provider-specific signatures live in adapter extractors and a central + fixture-driven catalogue. Kanban, Office, lifecycle, and UI code do not + inspect provider names or raw error strings to choose behavior. +- Structured metadata and exact signatures outrank broad text patterns. + Collision tests enforce deterministic priority. +- Adding a known provider message normally adds a sanitized fixture and + catalogue rule. It does not add an orchestration or UI branch. +- Classification is deterministic in this version. Calling a model to classify + an error or extract timing data is deferred. -### Kanban interactive recovery +### Error classes -- `network_unavailable`, `provider_overloaded`, `provider_unavailable`, - `model_capacity`, `agent_transport_lost`, and confirmed short `rate_limited` - failures are eligible for automatic retry. -- Before replaying, Kandev verifies that the prompt has produced no assistant - content or tool activity, unless the adapter provides a resumable retry - guarantee. A transient failure after potentially effectful progress remains a - manual recovery. -- An automatic retry preserves the original prompt, selected agent and execution - profiles, model, permissions, and turn configuration. -- Kandev makes at most five automatic attempts. Nominal backoff is 5, 10, 20, - 40, and 60 seconds with bounded jitter. A trustworthy short provider retry - hint can lengthen the next delay. -- Kandev never silently changes the selected model, account, agent, or provider, - and never purchases capacity. -- Exactly one backend schedule owns retry for a session and prompt generation. - A successful attempt clears recovery state. Exhaustion transitions to the - existing manual Resume and Start fresh choices. -- Authentication, credentials, subscription or billing action, plan quota, invalid model, - disabled model, and provider-configuration failures never schedule a Kanban - automatic retry. -- A known quota reset time or remediation destination is displayed, but does not - arm a Kanban timer. -- A permission denial, task error, or repository error follows its existing - terminal recovery behavior. -- An unknown error fails closed to generic manual recovery. Raw unclassified - evidence is not presented as trusted provider guidance. +The policy layer has two configurable classes: -### Office unattended routing +| Class | Meaning | Initial semantic codes and examples | +| --- | --- | --- | +| `transient` | The provider, network, transport, or selected model is temporarily unable to serve the request. | `network_unavailable`, `provider_unavailable`, `provider_overloaded`, `model_capacity`, confirmed short `rate_limited`, and launch-safe `agent_transport_lost` | +| `hard` | The selected account, subscription, credentials, provider configuration, or model cannot continue without a longer reset or user/configuration change. | `quota_limited`, `subscription_required`, `auth_required`, `missing_credentials`, `provider_not_configured`, and `model_unavailable` | -- Office consumes the shared code through its own routing policy. It never uses - Kanban's short prompt timer or retry-attempt budget. -- The short same-route phase applies to supported Office retry codes, even when - provider fallback is disabled. Fallback after exhaustion still requires an - enabled, explicitly configured alternative. -- `agent_transport_lost` is launch-only in Office. The launch path retries the - same execution profile three times, then stops routing that candidate without - degrading the provider or selecting a fallback. The post-start path declines - to handle this code, so the existing failure path handles the run. -- Network interruption, temporary provider unavailability/overload, model - capacity, and a validated rate-limit wait of at most 60 seconds first retry the - same execution profile after nominal 5, 10, and 20 second delays with jitter. -- During that short phase the failed provider is not degraded or excluded from - the run's route cycle. Three failed retries promote it to a degraded route; - Office may then fall through to the next configured execution profile. -- The retry attempt has one owning run, while the short cooldown is scoped to the - affected workspace/provider/model. Concurrent runs wait for that cooldown and - do not independently retry or switch providers. Success releases them; - exhaustion promotes the route to normal degradation/fallback. -- A rate-limit wait longer than 60 seconds, quota exhaustion, auth/subscription, - and model/configuration errors bypass the short phase and immediately use the - configured fallback policy. -- A route with auth, inactive-subscription, missing-configuration, or invalid - model evidence remains `user_action_required`; Office may try another route, - but does not schedule the blocked route itself. -- Renewable quota exhaustion bypasses the same-route short phase, falls through - to the next configured route, and leaves the affected route in durable - provider-health recovery using a validated reset hint or the minute-scale - backoff when no reset is known. -- Short rate limits, overload, and availability failures carry durable - provider-health retry state only after the short same-route phase is - exhausted. A validated reset hint wins; otherwise Office uses its - minute-scale 2, 5, 10, 20, and 60-minute backoff with jitter. -- When all routes are exhausted, Office durably parks the run as waiting for - provider capacity or blocked for provider action. The scheduler wakes waiting - runs, while blocked runs surface settings/inbox remediation. -- Non-provider run failures retain Office's separate generic scheduler retry - budget and CEO escalation. They are not reclassified as provider capacity. -- A pre-progress short retry may re-drive the exact prompt. After assistant or - tool progress, Office retries through a fresh same-provider session that first - inspects durable task and worktree state. Ambiguous prompt delivery is never - automatically repeated. +The semantic code remains authoritative diagnostic detail. The class is the +stable policy input. A catalogue revision can add new codes and signatures, +but it must assign every recoverable code to exactly one class. -### Workspace-specific progress +Task, repository, permission, local runtime, cancellation, and resume-state +errors are not provider errors. They retain their existing owner and cannot +trigger candidate switching through this policy. -- A scheduled Kanban retry appears as one localized inline warning in the task - chat. It shows a safe provider/model label when available, the next retry ordinal, - maximum attempts, and a live `Retrying in Ns` countdown. -- The backend sends the exact absolute `retry_at`; the browser derives the - countdown locally and does not write a new message every second. -- When the timer fires, the same surface changes to `Retrying now`. If another - transient failure occurs, it advances to the next attempt and new `retry_at`. -- The user can cancel the schedule. Sending another prompt, changing model or - profile, stopping the session, or starting fresh also cancels the old retry - before the new action proceeds. -- The UI announces scheduled, retrying, cancelled, recovered, and exhausted - transitions accessibly, but does not announce every countdown tick. -- Desktop and phone surfaces expose the same status and actions. Phone controls - retain at least a 44px touch target without horizontal page overflow. -- Office task/run detail, agent detail, dashboard, and inbox show route attempts, - actual provider/model, fallback reason, parked status, and next durable wake. - During short recovery they explicitly say the same provider will retry and - show its countdown and ordinal. Actions are Retry now, Try next provider now - when configured, or provider remediation. +Unrecognized, low-confidence, stale, or conflicting evidence has classification +state `unclassified`. It is not a third configurable class. It stops automatic +recovery and surfaces manual recovery so an unknown string cannot silently +repeat work or change providers. Historical attempts retain the semantic code, +class, rule ID, and catalogue version assigned when the attempt occurred. + +### Replay and effect-safety gate + +Classification does not by itself authorize retry or switching. + +- Automatic retry, reset waiting, or fallback requires evidence tied to the + current invocation and a failure boundary that is known to be pre-result and + effect-safe. +- A provider-supported resumable retry guarantee can satisfy this gate when it + identifies the same provider-native session and generation. +- Assistant output, tool activity, partial utility output, ambiguous prompt + delivery, or stale event ordering fails closed unless a durable continuation + package makes successor delivery safe under the dynamic-routing contract. +- User configuration cannot override this gate. An unsafe transient or hard + failure stops for manual recovery even when its class policy requests retry + or skip. + +### Per-class policy + +Each dynamic candidate stores one policy for `transient` and one for `hard`. +Each policy contains: + +| Field | Meaning | +| --- | --- | +| `retry.enabled` | Whether Kandev retries the same candidate before the final outcome | +| `retry.max_retries` | Maximum additional attempts after the failed attempt | +| `retry.initial_interval_seconds` | Delay before the first retry; later delays double exponentially | +| `wait_for_reset.enabled` | Whether a trustworthy future reset or retry time can replace the next retry delay | +| `wait_for_reset.max_wait_seconds` | Longest future reset interval this policy will wait | +| `on_exhausted` | `skip` to evaluate the next candidate, or `stop` for manual recovery | + +This shape makes retry, reset waiting, skip, and stop explicit without leaving +retry exhaustion undefined. Disabling retry and reset waiting applies +`on_exhausted` immediately. + +Backend limits are part of the contract: enabled retry accepts 1 through 10 +additional attempts, the initial interval accepts 1 second through 1 hour, and +each computed retry delay is capped at 24 hours. Enabled reset waiting accepts +1 second through 7 days. Disabled sections store zero values. The frontend +uses the same limits, while the backend remains authoritative. + +Policy evaluation follows this order: + +1. Reject stale, unclassified, or effect-unsafe failures. +2. If reset waiting is enabled, has not already been used for this candidate + and class in the current route cycle, and a validated future `retry_after` + or `reset_at` is no later than `max_wait_seconds`, persist a wait for that + deadline. +3. Otherwise, if retries remain, persist a retry of the same candidate. The + first delay is `initial_interval_seconds`; each later delay doubles from the + initial interval. Backend safety limits reject overflow and unbounded + schedules. +4. When the retry budget is exhausted or no wait applies, execute + `on_exhausted`: exclude the candidate for this route cycle when `skip`, or + enter manual recovery when `stop`. + +Waiting and its one post-wait attempt do not consume the exponential retry +budget. A candidate and class can use reset waiting at most once per route +cycle, so a repeated or revised hint cannot create an indefinite wait loop. A +reset at or before the current time is ignored. A reset beyond the configured +maximum is not shortened; Kandev proceeds to retry or the exhausted outcome. +Only validated structured hints or catalogue extractors may set timing +metadata. + +New and legacy candidates default to immediate `skip` for both classes: retry +and reset waiting disabled, `on_exhausted=skip`. This preserves the current +generic `try_next` behavior and avoids introducing hidden delays. Existing +`retry_same`, `try_next`, `stop`, and per-code rules are normalized once into +the versioned class-policy document: + +- `try_next` becomes no wait, no retry, then `skip` for both classes. +- `stop` becomes no wait, no retry, then `stop` for both classes. +- `retry_same` becomes one same-candidate retry after 5 seconds, then `stop`, + unless an existing explicit per-code rule overrides the mapped class. + +The backend returns the normalized document after create or update. The UI does +not write legacy rule shapes. + +### Durable scheduling and route ownership + +- The backend owns retry and reset timers. Browsers render absolute deadlines + and never dispatch retries from countdown completion. +- A dynamic route persists its candidate, generation, failure code, class, + catalogue version, policy snapshot, retry ordinal, deadline, and pending + outcome before work is scheduled. +- Retry and reset waits use the same generation fencing and single transition + owner as candidate switching. A timer, manual action, and provider event + cannot advance the route twice. +- Restart reconciliation re-arms only a provably undispatched schedule. An + ambiguous dispatch stops for manual recovery. +- User actions can retry now, skip now, cancel a pending wait, or stop. Each + action carries the expected generation and returns the authoritative route + snapshot. +- Shared resource circuits remain an eligibility input. A per-candidate policy + controls the current route response; circuit state prevents concurrent + sessions from creating a retry herd. Expired circuits recover through one + exclusive probe. + +### Use across Kanban, utility calls, and Office + +- A dynamic profile has one policy document regardless of caller. Kanban, + workflows, utility calls, and Office pass the same classified error and + policy snapshot to the shared evaluator. +- Callers do not copy provider error tables or retry schedules. They provide + invocation identity, effect evidence, and presentation context. +- Concrete-profile interactive recovery can retain its product-specific + defaults, but it consumes the same class and timing metadata rather than a + separate provider allow-list. +- Utility calls use a unique routing invocation ID and may recover only before + any partial result or effect. +- Office scheduler wake reasons do not modify candidate error policy. Office + reads the dynamic route state and presents the same retry, wait, skip, and + stop outcome. + +### User interface + +- Every dynamic candidate exposes separate Transient errors and Hard errors + sections. +- Each section explains the class with concrete examples in visible text. The + primary behavior is not hidden in a hover-only tooltip. +- Users can enable same-candidate retry, set maximum retries, set the initial + retry interval, enable reset waiting, set the maximum wait, and choose Skip + candidate or Stop after recovery is exhausted. +- The form shows the exponential schedule implied by the current values and + explains that reset waiting applies only to trusted future dates within the + maximum. +- Validation is inline and mirrored by the backend. Counts and durations must + be finite, non-negative, and within backend-owned limits. +- Desktop uses an expandable policy area inside each candidate row. Phone uses + the same direct settings route and a single-column disclosure layout with one + page scroll owner, 44px controls, and no horizontal document overflow. +- Active route surfaces show the class, safe cause, retry ordinal, absolute + deadline, and next outcome. They do not expose unsanitized provider text. ## Data model -The normalized internal classification contains: +The normalized classification contains: | Field | Meaning | | --- | --- | -| `code` | Stable semantic cause such as `model_capacity` or `quota_limited` | -| `confidence` | `high`, `medium`, or `low` classifier confidence | -| `scope` | Provider, account, model, or request scope when known | -| `classifier_rule` | Stable rule ID for tests, metrics, and diagnostics | +| `code` | Stable semantic cause | +| `class` | `transient`, `hard`, or internal `unclassified` state | +| `catalogue_version` | Deterministic catalogue version used for the result | +| `confidence` | `high`, `medium`, or `low` | +| `scope` | Provider, account, model, profile, or request scope when known | +| `classifier_rule` | Stable fixture-backed rule ID | | `provider_id` / `model_id` | Safe identifiers when present | -| `occurred_at` | Time of the correlated failure | -| `retry_after` / `reset_at` | Optional validated short retry hint or quota reset hint | -| `safe_excerpt` | Bounded sanitized evidence for collapsed technical details | +| `phase` / `occurred_at` | Correlated lifecycle phase and timestamp | +| `retry_after` / `reset_at` | Optional validated timing hints | +| `safe_excerpt` | Bounded sanitized evidence for technical details | -Each consumer adds its own policy result. Existing compatibility fields such as -`AutoRetryable`, `FallbackAllowed`, and `UserAction` become Office-policy -projections rather than classifier-owned universal truths. Kanban stores its -interactive disposition separately. +Dynamic profile policy documents are versioned and store the two class-policy +objects on each candidate. Legacy maps remain readable only for migration. -Persisted retry status contains: +Dynamic route state adds the persisted policy decision fields needed to resume +or reject a pending schedule. Attempt history is append-only and records the +policy snapshot, so later profile edits or catalogue changes do not rewrite why +an earlier decision occurred. -| Field | Meaning | -| --- | --- | -| `prompt_generation` | Turn identity that owns the retry | -| `failure_code` | Stable semantic cause | -| `interactive_disposition` | Kanban policy result | -| `retry_state` | `scheduled`, `dispatching`, `cancelled`, `recovered`, or `exhausted` | -| `retry_attempt` | Upcoming or active automatic retry ordinal; original prompt is not counted | -| `max_attempts` | Central attempt budget used by this schedule | -| `retry_at` | Exact UTC time for a scheduled retry | -| `provider_id` / `model_id` | Optional safe display labels | - -The status is Kanban-session scoped. Office keeps its existing durable -`office_provider_health`, `office_run_route_attempts`, and run routing fields. -Raw agent streams and unsanitized provider errors are not added to either model. - -Office extends its run-routing overlay with a durable short-retry phase, -same-route retry ordinal, and absolute retry deadline. A transient attempt uses -an outcome distinct from fallback exclusion; only short-budget exhaustion marks -that provider failed for the route cycle. - -Office provider health adds a non-degraded `short_retry` cooldown carrying the -affected scope and deadline. Durable ownership is represented by the run's -`current_route_attempt_seq` pointing at its `retry_scheduled` route-attempt row; -the short-retry ordinal is the count of those rows after the run's cycle -baseline. The run's `scheduled_retry_at` mirrors the cooldown deadline. This -mapping coordinates concurrent runs without making the provider eligible for -fallback before the short budget ends, and survives restart without adding an -owner column to provider health. +Raw streams, credentials, account identifiers, and unbounded error text are not +stored in policy or route state. ## API surface -- Existing session state and message payloads carry the structured retry status. - `retry_at` is an absolute UTC timestamp, never pre-rendered countdown text. -- The existing `session.recover` request accepts `cancel_retry` for the owning - session. Cancellation is idempotent. -- The backend publishes status changes when a retry is scheduled, begins, - succeeds, is cancelled, or exhausts its budget. One-second countdown ticks are - frontend-only presentation. -- Office routing continues to expose its durable route-health and route-attempt - events. Its policy derives fallback, health state, and scheduler eligibility - from the shared classification plus Office context. -- Office exposes a workspace-admin run action to skip a pending short wait and - advance to the next configured provider. It carries the expected route-attempt - sequence so a timer race cannot advance twice. +- Dynamic profile CRUD accepts and returns the versioned per-class policy + document. Unsupported versions, unknown classes, unknown outcomes, invalid + bounds, and incomplete class coverage return field-addressable validation + errors. +- Route state and event payloads expose stable error code, class, retry ordinal, + maximum retries, absolute deadline, and pending outcome. +- Manual route actions carry expected generation. Cancellation is idempotent. +- The classifier API is internal. Provider adapters submit evidence envelopes; + no public API accepts arbitrary user-authored regexes or class assignments. -## Kanban state machine +## State machine | State | Trigger | Next state | | --- | --- | --- | -| `classified` | High-confidence transient and replay-safe | `scheduled` | -| `classified` | User action, terminal, unknown, or replay-unsafe | manual recovery | -| `scheduled` | `retry_at` reached and generation still current | `dispatching` | -| `scheduled` | Cancel, new prompt, configuration change, stop, or stale generation | `cancelled` | -| `dispatching` | Prompt succeeds | `recovered` | -| `dispatching` | Another eligible transient and budget remains | `scheduled` | -| `dispatching` | Hard/unknown failure or budget exhausted | manual recovery / `exhausted` | +| `active` | Current, classified, effect-safe failure with eligible reset wait | `waiting_for_reset` | +| `active` | Current, classified, effect-safe failure with retry budget | `retry_wait` | +| `active` | Recovery exhausted with `skip` | `switching` | +| `active` | Recovery exhausted with `stop`, or unsafe/unclassified failure | `action_required` | +| `waiting_for_reset` | Deadline reached and generation current | `retrying` | +| `retry_wait` | Deadline reached and generation current | `retrying` | +| `waiting_for_reset` or `retry_wait` | Skip now | `switching` | +| `waiting_for_reset` or `retry_wait` | Cancel or stop | `action_required` | +| `retrying` | Success | `active` | +| `retrying` | Classified failure with budget remaining | `waiting_for_reset` or `retry_wait` | +| `retrying` | Recovery exhausted | `switching` or `action_required` | -Only the backend advances this state machine. The frontend renders it and sends -explicit user actions. Office retains the provider-route and task-resolution -state machines in [Office Provider Routing](../office/routing.md). +## Permissions -## Office short-retry state machine - -| State | Trigger | Next state | -| --- | --- | --- | -| `route_active` | High-confidence short transient | `same_route_short_retry` | -| `same_route_short_retry` | `retry_at` reached, attempt remains | retry same execution profile | -| `same_route_short_retry` | Retry succeeds | `route_active` | -| `same_route_short_retry` | Third retry fails | degrade route, then enabled configured fallback or parked capacity | -| `route_active` | Long-horizon or user-action provider failure | configured fallback or blocked/parked | -| `same_route_short_retry` | User selects Try next provider now | degrade route and configured fallback | - -Runs that encounter an existing route-scoped `short_retry` cooldown wait on its -deadline without consuming another retry attempt or considering fallback. +- Existing agent-profile management permission controls policy edits. +- Profile selection does not grant access to credentials, raw evidence, or + classifier internals. +- Office assignment permissions do not grant routing-policy mutation. ## Failure modes -- If a Codex-style failure is split across ACP metadata, a message, and - `end_turn`, only the correctly ordered, current-prompt sequence settles as an - error. A partial or stale sequence is ignored as authoritative evidence. -- If a classifier rule does not recognize a provider message, Kanban does not - automatically retry it and Office does not perform a post-start fallback. Its - bounded sanitized evidence can inform a later rule fixture. -- If two rules match, deterministic specificity and priority select one result; - registry collision tests prevent accidental broad-rule overrides. -- If a new semantic code lacks either a Kanban or Office policy entry, exhaustive - policy validation fails; no default may silently authorize retry or fallback. -- If a transient failure follows assistant output or tool activity and no safe - resume guarantee exists, Kandev does not replay it automatically. -- If a timer fires after a new prompt generation or session replacement, the - generation guard drops it before dispatch. -- If cancellation races the timer, one backend owner decides the outcome. A - cancelled schedule cannot dispatch afterward. -- If an Office same-route timer races a fallback request, the scheduler commits - exactly one transition; the original provider cannot launch after fallback - has selected a successor. -- If persistence of the visible status fails, Kandev cancels the schedule rather - than running an invisible automatic retry. -- If the backend restarts with a `scheduled` retry, it re-arms only after proving - the exact prompt/configuration is reconstructable. An ambiguous `dispatching` - retry becomes manual recovery to avoid duplicate delivery. -- If a browser clock is skewed, reaching zero changes only presentation; the - backend still owns actual dispatch time and the next state update corrects the - display. +- Unknown evidence stops automatic work and preserves sanitized diagnostic + context for catalogue growth. +- A timing hint that cannot be parsed, is in the past, or exceeds the policy + maximum does not schedule a wait. +- Invalid policy data is rejected at write time. Invalid persisted legacy data + loads as an actionable configuration error and never authorizes work. +- If durable state cannot be written, Kandev does not schedule the timer, + repeat the prompt, or launch a successor. +- If a timer races a manual action, generation fencing commits one outcome. +- If assistant output, tool activity, or a partial utility result makes replay + ambiguous, the configured policy is not applied automatically. +- If a new semantic code lacks a class assignment or policy coverage, + exhaustive registry tests fail and runtime classification is unclassified. +- If many sessions share an open resource circuit, one probe owns recovery and + the rest continue waiting or evaluate other candidates without stampede. ## Persistence guarantees -- Classification and retry status survive browser reloads through the existing - session message/state persistence. -- The frontend reconstructs a live countdown from persisted `retry_at` and the - current clock. -- Prompt generation and retry ordinal are durable enough to reject stale timers - and ACP frames. -- A backend restart never claims an in-memory timer still exists. It either - safely re-arms a provably undispatched durable schedule or changes the surface - to manual recovery. -- Sanitized classifier evidence follows existing diagnostic retention rules; raw - ACP/stderr streams do not become durable retry metadata. -- Office provider health, route attempts, parked-run status, and scheduler - deadlines retain their existing database-backed restart guarantees. -- Office's same-route retry phase and deadline are equally durable. Restart only - re-arms an attempt known not to have reached dispatch. +- Classification, catalogue version, policy snapshot, retry count, deadline, + and pending outcome survive reload and backend restart. +- Profile edits affect the next failure decision. They do not mutate a pending + timer or historical attempt unless the user cancels and retries explicitly. +- Absolute deadlines are stored in UTC. Browser clocks affect countdown display + only. +- A restart never repeats a dispatch whose completion is ambiguous. ## Scenarios -- **GIVEN** the active Luna/Codex prompt emits `systemError`, a selected-model - capacity message, and `end_turn`, **WHEN** the ordered evidence is correlated, - **THEN** Kandev classifies `model_capacity` and schedules the first safe retry. -- **GIVEN** Claude reports a structured 529 overload before producing content or - tool activity, **WHEN** it is classified, **THEN** the same retry lifecycle and - UI apply without a Claude-specific orchestration branch. -- **GIVEN** OpenCode emits a correlated temporary provider-unavailable diagnostic, - **WHEN** its adapter normalizes the evidence for a Kanban session, **THEN** the - Kanban policy can schedule the same short recovery. -- **GIVEN** any Kanban agent reports a subscription or plan usage limit, **WHEN** - it is classified, **THEN** Kandev shows the safe explanation and - reset/remediation hint but schedules no automatic retry. -- **GIVEN** an Office agent hits a renewable provider quota, **WHEN** automatic - routing is enabled, **THEN** Office marks that route degraded and immediately - tries the next configured execution profile. -- **GIVEN** an Office agent encounters a network reset or selected-model capacity - failure before producing work, **WHEN** routing is enabled, **THEN** Office - retries the same execution profile after a visible few-second countdown and - does not select a fallback provider. -- **GIVEN** that same short transient persists through three Office retries, - **WHEN** the final short attempt fails, **THEN** Office degrades the route and - tries the next configured execution profile. -- **GIVEN** Office fallback is disabled, **WHEN** the same short budget is - exhausted, **THEN** the run enters durable provider-capacity recovery and - never selects another provider. -- **GIVEN** several Office runs encounter the same model-capacity cooldown, - **WHEN** one run owns the short retry, **THEN** the other runs wait on that - deadline without consuming attempts, switching providers, or launching a - retry herd. -- **GIVEN** an Office rate limit carries a validated 10-minute retry hint, - **WHEN** another configured provider is available, **THEN** Office bypasses - the short phase and falls through immediately. -- **GIVEN** every Office route is quota-limited, **WHEN** the configured chain is - exhausted, **THEN** the run is durably parked and wakes at the earliest - validated reset or Office provider-health backoff. -- **GIVEN** an Office route requires an inactive subscription, **WHEN** another - configured route is available, **THEN** Office falls through to it while the - failed route remains blocked for user action and receives no timed retry. -- **GIVEN** an Office run is parked for provider capacity, **WHEN** the user opens - its task or dashboard on a phone, **THEN** the actual route, fallback reason, - next durable wake, and Retry now or remediation action remain available with - touch targets of at least 44px and no horizontal overflow. -- **GIVEN** an Office same-provider retry is pending, **WHEN** the user views the - run on a phone, **THEN** the provider, attempt ordinal, live countdown, Retry - now, and Try next provider now remain visible without a new drawer or - horizontal overflow. -- **GIVEN** a new provider error example maps to an existing semantic cause, - **WHEN** a developer adds its sanitized fixture and registry rule, **THEN** all - recovery consumers gain the behavior without lifecycle or UI changes. -- **GIVEN** an automatic retry is scheduled, **WHEN** the user reloads the task, - **THEN** the same attempt and live countdown render from the absolute - `retry_at`. -- **GIVEN** the countdown is visible on a phone, **WHEN** the user taps Cancel, - **THEN** the backend cancels the schedule through a touch target of at least - 44px and no later retry dispatches. -- **GIVEN** a transient failure occurred after a tool call, **WHEN** no resumable - retry guarantee exists, **THEN** Kandev offers manual recovery instead of - replaying the prompt. -- **GIVEN** five automatic retries all fail transiently, **WHEN** the budget is - exhausted, **THEN** the countdown stops and localized Resume and Start fresh - recovery actions appear. +- **GIVEN** a candidate returns a high-confidence capacity error before output, + **WHEN** its transient policy allows three retries starting at 5 seconds, + **THEN** Kandev persists waits of 5, 10, and 20 seconds before applying the + configured exhausted outcome. +- **GIVEN** a quota error includes a trusted reset one minute from now, + **WHEN** the hard policy allows reset waits up to five minutes, **THEN** the + route waits durably until the reset and retries the same candidate once. +- **GIVEN** the same reset is six hours away, **WHEN** the maximum wait is five + minutes, **THEN** Kandev does not shorten or wait for it and proceeds to the + configured retry or exhausted outcome. +- **GIVEN** a transient retry budget is exhausted with `on_exhausted=skip`, + **WHEN** another candidate is eligible, **THEN** the same logical dynamic + session advances to that candidate. +- **GIVEN** a hard policy has no retry or wait and ends in `stop`, **WHEN** the + provider reports exhausted credits, **THEN** the route enters manual recovery + without trying another provider. +- **GIVEN** an agent emits an unknown string error, **WHEN** no deterministic + catalogue rule matches, **THEN** Kandev stops automatic recovery and retains + only bounded sanitized evidence for a future fixture. +- **GIVEN** a failure follows tool activity, **WHEN** the candidate policy says + retry or skip, **THEN** effect safety overrides the policy and Kandev stops for + manual recovery. +- **GIVEN** the same dynamic profile is selected by Kanban and Office, **WHEN** + each sees the same classified, effect-safe error, **THEN** both apply the same + candidate policy and route transition. +- **GIVEN** a phone viewport, **WHEN** a user edits both class policies, **THEN** + all controls remain usable in one column with no horizontal page overflow. ## Out of scope - Inferring failure from inactivity alone. -- Automatically buying capacity, upgrading a subscription, authenticating an +- User-authored regular expressions or arbitrary remapping of semantic codes. +- Model or API calls that classify error text, stack traces, or timing hints. +- Automatically buying credits, upgrading subscriptions, authenticating an account, or changing provider configuration. -- Silently changing the selected agent, profile, model, account, or provider for - an interactive task session. -- Automatically scheduling a Kanban prompt at a distant subscription or quota - reset time. -- Treating arbitrary raw stderr or model-authored text as trusted error evidence. -- Unifying Kanban and Office retry state machines, schedules, or UI surfaces. +- Overriding effect-safety or generation fencing through profile settings. +- Telemetry, cost, or subscription-usage routing. Those remain in the separate + Dynamic Agent Telemetry Routing package.