iop/packages/go/agentprovider/catalog/factory.go

418 lines
13 KiB
Go

package catalog
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"sort"
"strings"
"go.uber.org/zap"
"iop/packages/go/agentconfig"
"iop/packages/go/agentguard"
"iop/packages/go/agentprovider/cli"
clistatus "iop/packages/go/agentprovider/cli/status"
runtime "iop/packages/go/agentruntime"
"iop/packages/go/config"
)
const modelPlaceholder = "{{model}}"
// ProfileProvider binds one validated catalog profile to the common CLI
// provider while preserving official identity on every host-facing result.
type ProfileProvider struct {
resolved agentconfig.ResolvedProfile
readiness Readiness
common *cli.CLI
}
// AdmittedProfileProvider is the only catalog facade intended for unattended
// AgentTask execution. It keeps the raw ProfileProvider private and requires a
// current guardrail Permit for every invocation.
type AdmittedProfileProvider struct {
provider *ProfileProvider
profile agentguard.ProviderProfile
}
// NewProfileProvider constructs a common runtime provider only for a matching
// ready discovery result.
func NewProfileProvider(
cfg agentconfig.Catalog,
profileID string,
readiness Readiness,
logger *zap.Logger,
) (*ProfileProvider, error) {
normalized, err := agentconfig.Normalize(cfg)
if err != nil {
return nil, err
}
resolved, ok := normalized.ResolveProfile(profileID)
if !ok {
return nil, fmt.Errorf("agent provider catalog: unknown profile %q", profileID)
}
if readiness.ProviderID != resolved.Provider.ID ||
readiness.ModelID != resolved.Model.ID ||
readiness.ProfileID != resolved.Profile.ID {
return nil, fmt.Errorf(
"agent provider catalog: readiness identity %s/%s/%s does not match profile %s/%s/%s",
readiness.ProviderID, readiness.ModelID, readiness.ProfileID,
resolved.Provider.ID, resolved.Model.ID, resolved.Profile.ID,
)
}
if !readiness.Ready() {
if readiness.Error != nil {
return nil, readiness.Error
}
return nil, fmt.Errorf(
"agent provider catalog: profile %q is not ready (state=%s)",
profileID, readiness.State,
)
}
if logger == nil {
logger = zap.NewNop()
}
profile := resolved.Profile
commonProfile := config.CLIProfileConf{
Command: resolved.Provider.Command,
Args: expandModel(profile.Args, resolved.Model.Target),
Env: append([]string(nil), profile.Env...),
Persistent: profile.Persistent,
Terminal: profile.Terminal,
ResponseIdleTimeoutMS: profile.ResponseIdleTimeoutMS,
StartupIdleTimeoutMS: profile.StartupIdleTimeoutMS,
OutputFormat: profile.OutputFormat,
Mode: profile.Mode,
ResumeArgs: expandModel(profile.ResumeArgs, resolved.Model.Target),
}
common := cli.New(config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
profile.ID: commonProfile,
},
}, logger)
common.StatusChecker = func(
ctx context.Context,
_ string,
profile config.CLIProfileConf,
) (*clistatus.UsageStatus, error) {
return clistatus.CheckUsage(ctx, resolved.Provider.ID, profile)
}
return &ProfileProvider{
resolved: resolved,
readiness: readiness,
common: common,
}, nil
}
// NewAdmittedProfileProvider constructs the mandatory admission facade for a
// catalog profile. Profiles that lack a required capability still construct so
// admission can return a typed, actionable blocker without invoking the CLI.
func NewAdmittedProfileProvider(
cfg agentconfig.Catalog,
profileID string,
readiness Readiness,
logger *zap.Logger,
) (*AdmittedProfileProvider, error) {
provider, err := NewProfileProvider(cfg, profileID, readiness, logger)
if err != nil {
return nil, err
}
return &AdmittedProfileProvider{
provider: provider,
profile: admissionProfile(provider.resolved, readiness),
}, nil
}
// AdmissionProfile returns the immutable, secret-free capability snapshot that
// callers use when persisting an admission request.
func (p *AdmittedProfileProvider) AdmissionProfile() agentguard.ProviderProfile {
return p.profile
}
// Admit evaluates the current workspace and isolation revisions against this
// facade's immutable provider profile.
func (p *AdmittedProfileProvider) Admit(
grant *agentguard.WorkspaceGrant,
isolation *agentguard.IsolationDescriptor,
) agentguard.AdmissionResult {
return agentguard.Admit(p.admissionRequest(grant, isolation))
}
// Execute revalidates the Permit and current inputs before calling the common
// CLI provider. A blocked result always means the provider invocation count is
// zero and no interactive fallback is attempted.
func (p *AdmittedProfileProvider) Execute(
ctx context.Context,
permit *agentguard.Permit,
grant *agentguard.WorkspaceGrant,
isolation *agentguard.IsolationDescriptor,
spec runtime.ExecutionSpec,
sink runtime.EventSink,
) (agentguard.AdmissionResult, error) {
request := p.admissionRequest(grant, isolation)
return agentguard.Invoke(
ctx,
permit,
request,
func(invokeCtx context.Context, workspace agentguard.CanonicalWorkspace) error {
spec.Workspace = workspace.WorkingDir
spec.Metadata = cloneMetadata(spec.Metadata)
spec.Metadata["workspace_grant_revision"] = workspace.GrantRevision
spec.Metadata["workspace_isolation_revision"] = workspace.IsolationRevision
spec.Metadata["workspace_base_revision"] = workspace.PinnedBaseRevision
return p.provider.Execute(invokeCtx, spec, sink)
},
)
}
func (p *AdmittedProfileProvider) admissionRequest(
grant *agentguard.WorkspaceGrant,
isolation *agentguard.IsolationDescriptor,
) agentguard.AdmissionRequest {
return agentguard.AdmissionRequest{
Grant: grant,
Isolation: isolation,
Profile: p.profile,
}
}
func (p *ProfileProvider) Name() string {
return "agent-cli:" + p.resolved.Provider.ID
}
func (p *ProfileProvider) Capabilities(ctx context.Context) (runtime.Capabilities, error) {
capabilities, err := p.common.Capabilities(ctx)
if err != nil {
return runtime.Capabilities{}, err
}
capabilities.InstanceKey = p.resolved.Profile.ID
capabilities.Targets = []string{p.resolved.Profile.ID}
capabilities.MaxConcurrency = p.resolved.Profile.MaxConcurrency
capabilities.ProviderStatus = runtime.ProviderStatusAvailable
return capabilities, nil
}
func (p *ProfileProvider) Execute(
ctx context.Context,
spec runtime.ExecutionSpec,
sink runtime.EventSink,
) error {
if spec.Target == "" {
spec.Target = p.resolved.Profile.ID
}
if spec.Target != p.resolved.Profile.ID {
return fmt.Errorf(
"agent provider catalog: profile %q cannot execute target %q",
p.resolved.Profile.ID, spec.Target,
)
}
if spec.Adapter == "" {
spec.Adapter = cli.Name
}
spec.Metadata = p.identityMetadata(spec.Metadata)
return p.common.Execute(ctx, spec, identitySink{provider: p, sink: sink})
}
// HandleCommand implements the common status/session command boundary. Usage
// status is enriched through the predecessor common CLI status API; a provider
// without a usable status surface retains the just-validated readiness snapshot.
func (p *ProfileProvider) HandleCommand(
ctx context.Context,
req runtime.CommandRequest,
) (runtime.CommandResponse, error) {
if req.Target == "" {
req.Target = p.resolved.Profile.ID
}
if req.Target != p.resolved.Profile.ID {
return runtime.CommandResponse{}, fmt.Errorf(
"agent provider catalog: profile %q cannot handle target %q",
p.resolved.Profile.ID, req.Target,
)
}
if req.Adapter == "" {
req.Adapter = cli.Name
}
switch req.Type {
case runtime.CommandTypeUsageStatus:
response, statusErr := p.common.HandleCommand(ctx, req)
if statusErr != nil || response.UsageStatus == nil {
response = runtime.CommandResponse{
RequestID: req.RequestID,
Type: req.Type,
Adapter: req.Adapter,
Target: req.Target,
SessionID: req.SessionID,
UsageStatus: &runtime.AgentUsageStatus{
Metadata: map[string]string{"status_probe": "readiness_fallback"},
},
}
} else {
response.UsageStatus.RawOutput = diagnostic(response.UsageStatus.RawOutput)
response.UsageStatus.Metadata = redactMetadata(response.UsageStatus.Metadata)
if response.UsageStatus.Metadata == nil {
response.UsageStatus.Metadata = make(map[string]string)
}
response.UsageStatus.Metadata["status_probe"] = "common_cli_status"
}
response.UsageStatus.Metadata["readiness"] = string(p.readiness.State)
response.UsageStatus.Metadata["version"] = p.readiness.Version
response.UsageStatus.Metadata = p.identityMetadata(response.UsageStatus.Metadata)
return response, nil
case runtime.CommandTypeSessionList:
response, err := p.common.HandleCommand(ctx, req)
if err != nil {
return runtime.CommandResponse{}, err
}
if response.Result == nil {
response.Result = make(map[string]string)
}
for key, value := range p.identityMetadata(nil) {
response.Result[key] = value
}
return response, nil
default:
return runtime.CommandResponse{}, fmt.Errorf(
"agent provider catalog: unsupported command %q",
req.Type,
)
}
}
// TerminateSession preserves logical-session termination separately from run
// cancellation.
func (p *ProfileProvider) TerminateSession(
ctx context.Context,
target, sessionID string,
) error {
if target == "" {
target = p.resolved.Profile.ID
}
if target != p.resolved.Profile.ID {
return fmt.Errorf(
"agent provider catalog: profile %q cannot terminate target %q",
p.resolved.Profile.ID, target,
)
}
return p.common.TerminateSession(ctx, target, sessionID)
}
func (p *ProfileProvider) Start(ctx context.Context) error {
return p.common.Start(ctx)
}
func (p *ProfileProvider) Stop(ctx context.Context) error {
return p.common.Stop(ctx)
}
func (p *ProfileProvider) identityMetadata(input map[string]string) map[string]string {
output := make(map[string]string, len(input)+3)
for key, value := range input {
output[key] = value
}
output["provider_id"] = p.resolved.Provider.ID
output["model_id"] = p.resolved.Model.ID
output["profile_id"] = p.resolved.Profile.ID
return output
}
func admissionProfile(
resolved agentconfig.ResolvedProfile,
readiness Readiness,
) agentguard.ProviderProfile {
capabilities := make(map[string]struct{}, len(resolved.Profile.Capabilities))
for _, capability := range resolved.Profile.Capabilities {
capabilities[capability] = struct{}{}
}
revisionInput := struct {
Resolved agentconfig.ResolvedProfile
Readiness struct {
Version string
Capabilities []string
}
}{
Resolved: resolved,
}
revisionInput.Readiness.Version = readiness.Version
revisionInput.Readiness.Capabilities = append([]string(nil), readiness.Capabilities...)
sort.Strings(revisionInput.Readiness.Capabilities)
encoded, err := json.Marshal(revisionInput)
if err != nil {
panic("agent provider catalog: encode admission profile revision: " + err.Error())
}
revision := sha256.Sum256(encoded)
readinessCapabilities := make(map[string]struct{}, len(readiness.Capabilities))
for _, capability := range readiness.Capabilities {
readinessCapabilities[capability] = struct{}{}
}
hasCapability := func(capability string) bool {
_, declared := capabilities[capability]
_, discovered := readinessCapabilities[capability]
return declared && discovered
}
return agentguard.ProviderProfile{
ProviderID: resolved.Provider.ID,
ModelID: resolved.Model.ID,
ProfileID: resolved.Profile.ID,
Revision: fmt.Sprintf("%x", revision[:]),
Unattended: hasCapability("unattended"),
ApprovalBypass: hasCapability("approval_bypass"),
WritableRootConfinement: hasCapability("writable_root_confinement"),
}
}
func cloneMetadata(input map[string]string) map[string]string {
output := make(map[string]string, len(input)+3)
for key, value := range input {
output[key] = value
}
return output
}
type identitySink struct {
provider *ProfileProvider
sink runtime.EventSink
}
func (s identitySink) Emit(ctx context.Context, event runtime.RuntimeEvent) error {
event.Metadata = s.provider.identityMetadata(event.Metadata)
event.Error = Redact(event.Error)
event.Message = Redact(event.Message)
if event.Failure != nil {
failure := *event.Failure
failure.Message = Redact(failure.Message)
failure.Metadata = s.provider.identityMetadata(redactMetadata(failure.Metadata))
event.Failure = &failure
}
return s.sink.Emit(ctx, event)
}
func redactMetadata(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
output := make(map[string]string, len(input))
for key, value := range input {
output[key] = Redact(value)
}
return output
}
func expandModel(arguments []string, target string) []string {
if arguments == nil {
return nil
}
expanded := make([]string, len(arguments))
for index, argument := range arguments {
expanded[index] = strings.ReplaceAll(argument, modelPlaceholder, target)
}
return expanded
}
var (
_ runtime.Provider = (*ProfileProvider)(nil)
_ runtime.CommandHandler = (*ProfileProvider)(nil)
_ runtime.SessionTerminator = (*ProfileProvider)(nil)
)