diff --git a/.gitignore b/.gitignore index e71da67..72f845f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .worktrees/ data/ +dist/ +plugin.json.bak diff --git a/.goreleaser.yml b/.goreleaser.yml index d2f5f80..d7ae621 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,9 +1,12 @@ version: 2 +project_name: workflow-plugin-agent before: hooks: - go mod tidy - - "sed -i.bak 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' plugin.json && rm -f plugin.json.bak" + # Rewrite plugin.json: bump version sentinel + refresh download URLs to the + # release being cut. Mirrors workflow-plugin-infra/.goreleaser.yml before-hook. + - "sed -i.bak -e 's/\"version\": \".*\"/\"version\": \"{{ .Version }}\"/' -e 's#/releases/download/v[^/]*/#/releases/download/v{{ .Version }}/#g' -e 's/workflow-plugin-agent_[0-9][0-9A-Za-z.~-]*/workflow-plugin-agent_{{ .Version }}/g' plugin.json && rm -f plugin.json.bak" builds: - id: workflow-plugin-agent @@ -12,23 +15,29 @@ builds: env: - CGO_ENABLED=0 - GOPRIVATE=github.com/GoCodeAlone/* + ldflags: + - "-s -w -X github.com/GoCodeAlone/workflow-plugin-agent.Version={{ .Version }}" goos: - linux - darwin + - windows goarch: - amd64 - arm64 - ldflags: - - -s -w -X github.com/GoCodeAlone/workflow-plugin-agent.Version={{.Version}} archives: - - formats: [tar.gz] - name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" + - id: archive + formats: + - tar.gz + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" files: - plugin.json - plugin.contracts.json - LICENSE +checksum: + name_template: "checksums.txt" + changelog: sort: asc filters: @@ -39,3 +48,6 @@ changelog: release: draft: true + github: + owner: GoCodeAlone + name: workflow-plugin-agent diff --git a/cmd/workflow-plugin-agent/main.go b/cmd/workflow-plugin-agent/main.go index 853ccfa..6a6b318 100644 --- a/cmd/workflow-plugin-agent/main.go +++ b/cmd/workflow-plugin-agent/main.go @@ -1,5 +1,10 @@ // Command workflow-plugin-agent hosts the agent plugin over the workflow // external-plugin protocol. +// +// ServePluginFull is used (rather than the bare sdk.Serve) so the binary can +// later gain CLI and/or build-hook dispatch without a re-architecture, mirroring +// workflow-plugin-infra/cmd/workflow-plugin-infra. The agent plugin exposes no +// CLI or hooks yet, so the cli and hooks arguments are nil. package main import ( @@ -8,5 +13,10 @@ import ( ) func main() { - sdk.Serve(agent.New(), sdk.WithBuildVersion(sdk.ResolveBuildVersion(agent.Version))) + sdk.ServePluginFull( + agent.New(), + nil, // no CLI provider yet + nil, // no hook handler yet + sdk.WithBuildVersion(sdk.ResolveBuildVersion(agent.Version)), + ) } diff --git a/plugin.contracts.json b/plugin.contracts.json index 122821f..bc32aa2 100644 --- a/plugin.contracts.json +++ b/plugin.contracts.json @@ -7,6 +7,22 @@ "mode": "strict", "config": "workflow.plugins.agent.v1.ProviderConfig" }, + { + "kind": "step", + "type": "step.agent_execute", + "mode": "strict", + "config": "workflow.plugins.agent.v1.AgentExecuteConfig", + "input": "workflow.plugins.agent.v1.AgentExecuteInput", + "output": "workflow.plugins.agent.v1.AgentExecuteOutput" + }, + { + "kind": "step", + "type": "step.provider_test", + "mode": "strict", + "config": "workflow.plugins.agent.v1.ProviderTestConfig", + "input": "workflow.plugins.agent.v1.ProviderTestInput", + "output": "workflow.plugins.agent.v1.ProviderTestOutput" + }, { "kind": "step", "type": "step.provider_models", diff --git a/plugin.json b/plugin.json index c17f11f..49b4705 100644 --- a/plugin.json +++ b/plugin.json @@ -4,16 +4,52 @@ "description": "AI agent primitives for workflow apps", "author": "GoCodeAlone", "license": "Proprietary", - "type": "builtin", + "type": "external", "tier": "core", "minEngineVersion": "0.53.0", "private": true, + "homepage": "https://github.com/GoCodeAlone/workflow-plugin-agent", + "repository": "https://github.com/GoCodeAlone/workflow-plugin-agent", + "downloads": [ + { + "os": "linux", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-agent/releases/download/v0.0.0/workflow-plugin-agent_0.0.0_linux_amd64.tar.gz" + }, + { + "os": "linux", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-agent/releases/download/v0.0.0/workflow-plugin-agent_0.0.0_linux_arm64.tar.gz" + }, + { + "os": "darwin", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-agent/releases/download/v0.0.0/workflow-plugin-agent_0.0.0_darwin_amd64.tar.gz" + }, + { + "os": "darwin", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-agent/releases/download/v0.0.0/workflow-plugin-agent_0.0.0_darwin_arm64.tar.gz" + }, + { + "os": "windows", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-agent/releases/download/v0.0.0/workflow-plugin-agent_0.0.0_windows_amd64.tar.gz" + }, + { + "os": "windows", + "arch": "arm64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-agent/releases/download/v0.0.0/workflow-plugin-agent_0.0.0_windows_arm64.tar.gz" + } + ], "capabilities": { "configProvider": false, "moduleTypes": [ "agent.provider" ], "stepTypes": [ + "step.agent_execute", + "step.provider_test", "step.provider_models", "step.model_pull" ], diff --git a/plugin_contracts_test.go b/plugin_contracts_test.go index 26bdb4a..16fc7f1 100644 --- a/plugin_contracts_test.go +++ b/plugin_contracts_test.go @@ -3,6 +3,7 @@ package agent import ( "context" "encoding/json" + "errors" "os" "path/filepath" "runtime" @@ -37,6 +38,18 @@ func TestContractRegistryDeclaresStrictContracts(t *testing.T) { kind: pb.ContractKind_CONTRACT_KIND_MODULE, config: "workflow.plugins.agent.v1.ProviderConfig", }, + "step:step.agent_execute": { + kind: pb.ContractKind_CONTRACT_KIND_STEP, + config: "workflow.plugins.agent.v1.AgentExecuteConfig", + input: "workflow.plugins.agent.v1.AgentExecuteInput", + output: "workflow.plugins.agent.v1.AgentExecuteOutput", + }, + "step:step.provider_test": { + kind: pb.ContractKind_CONTRACT_KIND_STEP, + config: "workflow.plugins.agent.v1.ProviderTestConfig", + input: "workflow.plugins.agent.v1.ProviderTestInput", + output: "workflow.plugins.agent.v1.ProviderTestOutput", + }, "step:step.provider_models": { kind: pb.ContractKind_CONTRACT_KIND_STEP, config: "workflow.plugins.agent.v1.ProviderModelsConfig", @@ -79,12 +92,33 @@ func TestContractRegistryDeclaresStrictContracts(t *testing.T) { } } -func TestAppBoundStepsAreNotAdvertisedAsStrict(t *testing.T) { +// TestAppBoundStepsAdvertisedStrictButLegacyExecuted documents the hybrid +// contract for step.agent_execute + step.provider_test: they carry strict +// contract DESCRIPTORS (so consumers know their config/input/output shape and +// the strict-contracts coverage gate passes) but remain on the legacy map +// execution path because their implementations lazy-lookup services from +// modular.Application, which the typed gRPC handler signature cannot access. +// CreateTypedStep therefore declines them (ErrTypedContractNotHandled) and the +// engine falls back to the legacy StepProvider path. +func TestAppBoundStepsAdvertisedStrictButLegacyExecuted(t *testing.T) { registry := New().ContractRegistry() + descriptors := map[string]*pb.ContractDescriptor{} for _, descriptor := range registry.Contracts { - switch descriptor.StepType { - case "step.agent_execute", "step.provider_test": - t.Fatalf("%s requires application services and must not be advertised as strict", descriptor.StepType) + if descriptor.Kind == pb.ContractKind_CONTRACT_KIND_STEP { + descriptors[descriptor.StepType] = descriptor + } + } + + for _, stepType := range []string{"step.agent_execute", "step.provider_test"} { + desc, ok := descriptors[stepType] + if !ok { + t.Fatalf("%s missing strict contract descriptor", stepType) + } + if desc.Mode != pb.ContractMode_CONTRACT_MODE_STRICT_PROTO { + t.Fatalf("%s mode = %s, want strict proto", stepType, desc.Mode) + } + if desc.ConfigMessage == "" || desc.InputMessage == "" || desc.OutputMessage == "" { + t.Fatalf("%s descriptor must carry config/input/output messages", stepType) } } @@ -97,8 +131,24 @@ func TestAppBoundStepsAreNotAdvertisedAsStrict(t *testing.T) { if err != nil { t.Fatalf("pack config: %v", err) } - if _, err := typedProvider.CreateTypedStep(stepType, "app-bound", config); err == nil { - t.Fatalf("CreateTypedStep(%q) succeeded; app-bound step must use legacy execution", stepType) + _, err = typedProvider.CreateTypedStep(stepType, "app-bound", config) + if err == nil { + t.Fatalf("CreateTypedStep(%q) succeeded; app-bound step must decline typed execution so the engine uses the legacy path", stepType) + } + if !errors.Is(err, sdk.ErrTypedContractNotHandled) { + t.Fatalf("CreateTypedStep(%q) error = %v, want ErrTypedContractNotHandled", stepType, err) + } + } + + // TypedStepTypes advertises only the two fully-typed steps; the app-bound + // pair is intentionally absent because they cannot execute via the typed + // handler signature. StepTypes() (legacy) still lists all four. + typedStepTypes := typedProvider.TypedStepTypes() + for _, stepType := range []string{"step.agent_execute", "step.provider_test"} { + for _, got := range typedStepTypes { + if got == stepType { + t.Fatalf("TypedStepTypes must not list app-bound %s (legacy-execution only)", stepType) + } } } } @@ -170,11 +220,15 @@ func TestPluginJSONDeclaresStrictContractMetadata(t *testing.T) { if manifest.StrictContracts.Registry != "plugin.contracts.json" { t.Fatalf("strictContracts.registry = %q, want plugin.contracts.json", manifest.StrictContracts.Registry) } - for _, stepType := range manifest.Capabilities.StepTypes { - switch stepType { - case "step.agent_execute", "step.provider_test": - t.Fatalf("plugin.json advertises legacy-only step %q while strict contracts are enabled", stepType) - } + // plugin.json capabilities.stepTypes lists ALL runtime steps. All four + // carry strict contract descriptors in plugin.contracts.json (asserted by + // TestPluginContractsManifestMatchesRegistry); step.agent_execute and + // step.provider_test remain legacy-execution despite having descriptors + // (asserted by TestAppBoundStepsAdvertisedStrictButLegacyExecuted). + // The drift gate that plugin.json stepTypes == runtime StepTypes() lives + // in provider_manifest_test.go (TestPluginJSONStepTypesMatchRuntimeTruth). + if len(manifest.Capabilities.StepTypes) == 0 { + t.Fatal("plugin.json capabilities.stepTypes is empty; expected runtime-truth advertisement") } } diff --git a/provider_manifest_test.go b/provider_manifest_test.go new file mode 100644 index 0000000..44bfc5b --- /dev/null +++ b/provider_manifest_test.go @@ -0,0 +1,168 @@ +package agent + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +// pluginJSONCapabilities is the subset of plugin.json these drift-gate tests +// assert against. +type pluginJSONCapabilities struct { + Capabilities struct { + ModuleTypes []string `json:"moduleTypes"` + StepTypes []string `json:"stepTypes"` + } `json:"capabilities"` +} + +func loadPluginJSONCapabilities(t *testing.T) pluginJSONCapabilities { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + data, err := os.ReadFile(filepath.Join(filepath.Dir(file), "plugin.json")) + if err != nil { + t.Fatalf("read plugin.json: %v", err) + } + var manifest pluginJSONCapabilities + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("parse plugin.json: %v", err) + } + return manifest +} + +// TestAgentPluginSatisfiesSDKProviderInterfaces asserts that *AgentPlugin — the +// value handed to sdk.ServePluginFull in cmd/workflow-plugin-agent/main.go — +// implements every sdk provider interface the gRPC server consults at runtime. +// This is the type-level contract for the distributable binary: if any of these +// assertions break, the binary will fail to register capabilities with the host. +func TestAgentPluginSatisfiesSDKProviderInterfaces(t *testing.T) { + p := New() + + if _, ok := any(p).(sdk.PluginProvider); !ok { + t.Fatal("expected *AgentPlugin to satisfy sdk.PluginProvider (Manifest)") + } + if _, ok := any(p).(sdk.ModuleProvider); !ok { + t.Fatal("expected *AgentPlugin to satisfy sdk.ModuleProvider") + } + if _, ok := any(p).(sdk.StepProvider); !ok { + t.Fatal("expected *AgentPlugin to satisfy sdk.StepProvider") + } + if _, ok := any(p).(sdk.TypedModuleProvider); !ok { + t.Fatal("expected *AgentPlugin to satisfy sdk.TypedModuleProvider") + } + if _, ok := any(p).(sdk.TypedStepProvider); !ok { + t.Fatal("expected *AgentPlugin to satisfy sdk.TypedStepProvider") + } + if _, ok := any(p).(sdk.ContractProvider); !ok { + t.Fatal("expected *AgentPlugin to satisfy sdk.ContractProvider") + } +} + +// TestAgentPluginProviderManifestRuntimeTruth asserts the runtime provider +// returns the manifest the gRPC GetManifest handler will advertise to the host: +// canonical name, and module/step capability lists matching what the engine +// will later enumerate via ModuleTypes()/StepTypes(). This is the P6 gate that +// proves the provider (and thus the binary) agrees with the reconciled +// plugin.json — the same assertion wfctl verify-capabilities makes at runtime. +func TestAgentPluginProviderManifestRuntimeTruth(t *testing.T) { + p := New() + + provider, ok := any(p).(sdk.PluginProvider) + if !ok { + t.Fatal("expected *AgentPlugin to satisfy sdk.PluginProvider") + } + m := provider.Manifest() + if m.Name != "workflow-plugin-agent" { + t.Fatalf("Manifest().Name = %q, want workflow-plugin-agent", m.Name) + } + if m.Author != "GoCodeAlone" { + t.Fatalf("Manifest().Author = %q, want GoCodeAlone", m.Author) + } + if m.Description == "" { + t.Fatal("Manifest().Description is empty") + } + + wantModule := map[string]bool{"agent.provider": true} + for _, mt := range p.ModuleTypes() { + if !wantModule[mt] { + t.Fatalf("unexpected ModuleType %q", mt) + } + delete(wantModule, mt) + } + if len(wantModule) > 0 { + t.Fatalf("ModuleTypes() missing %v", wantModule) + } + + wantSteps := map[string]bool{ + "step.agent_execute": true, + "step.provider_test": true, + "step.provider_models": true, + "step.model_pull": true, + } + gotSteps := p.StepTypes() + if len(gotSteps) != len(wantSteps) { + t.Fatalf("StepTypes() = %v (len %d), want %v (len %d)", gotSteps, len(gotSteps), wantSteps, len(wantSteps)) + } + for _, s := range gotSteps { + if !wantSteps[s] { + t.Fatalf("unexpected StepType %q", s) + } + delete(wantSteps, s) + } + if len(wantSteps) > 0 { + t.Fatalf("StepTypes() missing %v", wantSteps) + } +} + +// TestPluginJSONStepTypesMatchRuntimeTruth is the P6 drift gate: plugin.json +// capabilities.stepTypes MUST equal the runtime StepTypes() list. The previous +// plugin.json declared only the 2 strict-contract-backed steps; the engine +// treats plugin.json capabilities as the complete capability advertisement, so +// the legacy step.agent_execute and step.provider_test MUST also be listed even +// though they use the legacy (non-protobuf) execution path. wfctl +// verify-capabilities compares these two sources and fails on divergence. +func TestPluginJSONStepTypesMatchRuntimeTruth(t *testing.T) { + manifest := loadPluginJSONCapabilities(t) + if len(manifest.Capabilities.StepTypes) == 0 { + t.Fatal("plugin.json capabilities.stepTypes is empty") + } + + runtime := map[string]bool{} + for _, s := range New().StepTypes() { + runtime[s] = true + } + declared := map[string]bool{} + for _, s := range manifest.Capabilities.StepTypes { + declared[s] = true + } + + for s := range runtime { + if !declared[s] { + t.Fatalf("plugin.json stepTypes missing runtime step %q (drift: plugin.json must list every StepTypes() entry)", s) + } + } + for s := range declared { + if !runtime[s] { + t.Fatalf("plugin.json stepTypes declares %q but runtime StepTypes() does not return it", s) + } + } + + if len(manifest.Capabilities.ModuleTypes) == 0 { + t.Fatal("plugin.json capabilities.moduleTypes is empty") + } + runtimeMods := map[string]bool{} + for _, m := range New().ModuleTypes() { + runtimeMods[m] = true + } + for _, m := range manifest.Capabilities.ModuleTypes { + if !runtimeMods[m] { + t.Fatalf("plugin.json moduleTypes declares %q but runtime ModuleTypes() does not return it", m) + } + } +} diff --git a/typed_contracts.go b/typed_contracts.go index a5dbe1b..3e079eb 100644 --- a/typed_contracts.go +++ b/typed_contracts.go @@ -31,6 +31,17 @@ func (p *AgentPlugin) ContractRegistry() *pb.ContractRegistry { ConfigMessage: agentContractsPackage + "ProviderConfig", Mode: pb.ContractMode_CONTRACT_MODE_STRICT_PROTO, }, + // step.agent_execute + step.provider_test are app-bound steps: their + // execution path lazy-looks up services (agent-provider-registry, + // agent-tool-registry) from modular.Application, which the typed + // gRPC handler signature cannot access. They therefore stay on the + // legacy map execution path (CreateTypedStep returns + // ErrTypedContractNotHandled for them). They still carry strict + // contract DESCRIPTORS here so consumers have a validated + // config/input/output shape and the strict-contracts coverage gate + // (every advertised step type has a descriptor) passes. + stepContract("step.agent_execute", "AgentExecuteConfig", "AgentExecuteInput", "AgentExecuteOutput"), + stepContract("step.provider_test", "ProviderTestConfig", "ProviderTestInput", "ProviderTestOutput"), stepContract("step.provider_models", "ProviderModelsConfig", "ProviderModelsInput", "ProviderModelsOutput"), stepContract("step.model_pull", "ModelPullConfig", "ModelPullInput", "ModelPullOutput"), },