Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

181 lines
7.0 KiB
Go

// Package modelbinding orchestrates per-workspace agent target model
// bindings: which model access plan, default model, and model usage policy an
// agent target uses for new sessions. It also resolves plan references so
// plan mutations can show impact and deletion stays guarded.
package modelbinding
import (
"context"
"errors"
"fmt"
"strings"
"time"
agentproviderbiz "github.com/tutti-os/tutti/services/tuttid/biz/agentprovider"
agenttargetbiz "github.com/tutti-os/tutti/services/tuttid/biz/agenttarget"
modelbindingbiz "github.com/tutti-os/tutti/services/tuttid/biz/modelbinding"
modelplanbiz "github.com/tutti-os/tutti/services/tuttid/biz/modelplan"
modelpolicybiz "github.com/tutti-os/tutti/services/tuttid/biz/modelpolicy"
workspacedata "github.com/tutti-os/tutti/services/tuttid/data/workspace"
)
var (
ErrInvalidBindingInput = errors.New("invalid agent model binding input")
ErrPlanNotUsable = errors.New("model plan is not usable for binding")
ErrModelNotInPlan = errors.New("model is not part of the referenced plan")
ErrPolicyNotUsable = errors.New("model usage policy is not usable for binding")
// ErrBindingReferenceUnusable reports that a referenced plan or policy
// became unusable between validation and commit (the store's foreign keys
// reject the write). SQLite does not identify which reference failed, so the
// message stays neutral rather than claiming a specific one.
ErrBindingReferenceUnusable = errors.New("agent model binding reference became unusable")
)
// TargetResolver confirms an agent target id exists before binding it and
// supplies display names for reference impact listings.
type TargetResolver interface {
GetAgentTarget(ctx context.Context, id string) (agenttargetbiz.Target, error)
}
// PolicyLookup confirms a model usage policy exists in the workspace before a
// binding may reference it. It is a narrow read over biz types so binding
// validation never takes a modelbinding -> modelpolicy service dependency.
type PolicyLookup interface {
GetModelPolicy(ctx context.Context, workspaceID string, policyID string) (modelpolicybiz.Policy, error)
}
type Service struct {
Store workspacedata.AgentModelBindingsStore
Plans workspacedata.ModelPlansStore
Targets TargetResolver
Policies PolicyLookup
Now func() time.Time
}
type SetBindingInput struct {
WorkspaceID string
AgentTargetID string
ModelPlanID string
DefaultModel string
ModelPolicyID string
}
// SetBinding stores or clears one agent target binding. An all-empty input
// removes the binding. Changes affect only sessions that have not started.
func (s *Service) SetBinding(ctx context.Context, input SetBindingInput) (modelbindingbiz.Binding, error) {
binding, err := modelbindingbiz.Normalize(modelbindingbiz.Binding{
WorkspaceID: input.WorkspaceID,
AgentTargetID: input.AgentTargetID,
ModelPlanID: input.ModelPlanID,
DefaultModel: input.DefaultModel,
ModelPolicyID: input.ModelPolicyID,
UpdatedAt: s.now(),
})
if err != nil {
return modelbindingbiz.Binding{}, fmt.Errorf("%w: %w", ErrInvalidBindingInput, err)
}
var target agenttargetbiz.Target
if s.Targets != nil {
var err error
target, err = s.Targets.GetAgentTarget(ctx, binding.AgentTargetID)
if err != nil {
return modelbindingbiz.Binding{}, err
}
}
if binding.IsZero() {
if err := s.Store.DeleteAgentModelBinding(ctx, binding.WorkspaceID, binding.AgentTargetID); err != nil && !errors.Is(err, workspacedata.ErrAgentModelBindingNotFound) {
return modelbindingbiz.Binding{}, err
}
return binding, nil
}
if binding.ModelPlanID != "" {
plan, err := s.Plans.GetModelPlan(ctx, binding.WorkspaceID, binding.ModelPlanID)
if err != nil {
if errors.Is(err, workspacedata.ErrModelPlanNotFound) {
return modelbindingbiz.Binding{}, fmt.Errorf("%w: plan not found", ErrPlanNotUsable)
}
return modelbindingbiz.Binding{}, err
}
if binding.DefaultModel != "" && !modelplanbiz.ModelsContain(plan.Models, binding.DefaultModel) {
return modelbindingbiz.Binding{}, ErrModelNotInPlan
}
if s.Targets != nil {
protocol, ok := agentproviderbiz.ModelPlanProtocol(target.Provider)
if !ok {
return modelbindingbiz.Binding{}, fmt.Errorf("%w: agent provider %q does not support model plan bindings", ErrPlanNotUsable, target.Provider)
}
if protocol != string(plan.Protocol) {
return modelbindingbiz.Binding{}, fmt.Errorf("%w: agent provider %q requires %s protocol, plan uses %s", ErrPlanNotUsable, target.Provider, protocol, plan.Protocol)
}
}
}
if binding.ModelPolicyID != "" {
if s.Policies == nil {
// Fail closed: never persist a policy link that cannot be validated.
return modelbindingbiz.Binding{}, errors.New("model usage policy validation is unavailable")
}
if _, err := s.Policies.GetModelPolicy(ctx, binding.WorkspaceID, binding.ModelPolicyID); err != nil {
if errors.Is(err, workspacedata.ErrModelPolicyNotFound) {
return modelbindingbiz.Binding{}, fmt.Errorf("%w: policy not found", ErrPolicyNotUsable)
}
return modelbindingbiz.Binding{}, err
}
}
if err := s.Store.PutAgentModelBinding(ctx, binding); err != nil {
if errors.Is(err, workspacedata.ErrAgentModelBindingReferenceInvalid) {
// A plan or policy referenced by this binding disappeared between the
// pre-validation above and the write; the store cannot say which, so
// surface a neutral, stable error rather than blaming the plan.
return modelbindingbiz.Binding{}, ErrBindingReferenceUnusable
}
return modelbindingbiz.Binding{}, err
}
return binding, nil
}
func (s *Service) GetBinding(ctx context.Context, workspaceID string, agentTargetID string) (modelbindingbiz.Binding, error) {
return s.Store.GetAgentModelBinding(ctx, strings.TrimSpace(workspaceID), strings.TrimSpace(agentTargetID))
}
func (s *Service) ListBindings(ctx context.Context, workspaceID string) ([]modelbindingbiz.Binding, error) {
bindings, err := s.Store.ListAgentModelBindings(ctx, strings.TrimSpace(workspaceID))
if err != nil {
return nil, err
}
if bindings == nil {
bindings = []modelbindingbiz.Binding{}
}
return bindings, nil
}
// ListModelPlanReferences implements the model plan reference contract for
// agent target bindings.
func (s *Service) ListModelPlanReferences(ctx context.Context, workspaceID string, planID string) ([]modelplanbiz.Reference, error) {
bindings, err := s.Store.ListAgentModelBindingsByPlan(ctx, strings.TrimSpace(workspaceID), strings.TrimSpace(planID))
if err != nil {
return nil, err
}
references := make([]modelplanbiz.Reference, 0, len(bindings))
for _, binding := range bindings {
reference := modelplanbiz.Reference{
Kind: modelplanbiz.ReferenceAgentTarget,
ID: binding.AgentTargetID,
Role: "default",
}
if s.Targets != nil {
if target, err := s.Targets.GetAgentTarget(ctx, binding.AgentTargetID); err == nil {
reference.Name = target.Name
}
}
references = append(references, reference)
}
return references, nil
}
func (s *Service) now() time.Time {
if s.Now != nil {
return s.Now().UTC()
}
return time.Now().UTC()
}