diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a7f7d861bb..e1ab85d2f9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -514,6 +514,9 @@ jobs: # Validate packaged runtime defaults without accessing an image registry. run: bash scripts/package-release-charts_test.sh + - name: Test CLI install script + run: bash scripts/get-kagent_test.sh + - name: Run helm unit tests run: | helm unittest helm/kagent diff --git a/.github/workflows/tag.yaml b/.github/workflows/tag.yaml index 498469d2ff..d640b32f3c 100644 --- a/.github/workflows/tag.yaml +++ b/.github/workflows/tag.yaml @@ -7,6 +7,11 @@ on: description: 'Release tag to create (v prefix required, e.g. v0.1.0)' required: true type: string + latest: + description: 'Mark as GitHub latest, the release the CLI install script installs. Clear for patches to an older line.' + required: true + type: boolean + default: true concurrency: group: release @@ -173,6 +178,10 @@ jobs: tag_name: ${{ needs.setup.outputs.tag }} target_commitish: ${{ github.sha }} generate_release_notes: true + # GitHub cannot mark a prerelease latest, so a suffixed tag (v1.0.0-rc.1) + # is a prerelease only when it is not released as latest. + prerelease: ${{ !inputs.latest && contains(inputs.version, '-') }} + make_latest: ${{ inputs.latest }} files: | go/core/bin/kagent-* dist/kagent-*.tgz diff --git a/docs/architecture/configuration-and-compilation.md b/docs/architecture/configuration-and-compilation.md index bd37a836b4..bfb4147fcd 100644 --- a/docs/architecture/configuration-and-compilation.md +++ b/docs/architecture/configuration-and-compilation.md @@ -65,6 +65,25 @@ new revision; create a new Session to use it. A2A task events remain streamable when model streaming is disabled. This setting does not configure the Codex or Claude harnesses. +### Default Harnesses + +`kagent install` creates the release WorkerPool and three Harnesses in the +release namespace: `kagent-default`, `codex-default`, and `claude-default`. Each +selects its runtime, references the release WorkerPool, uses the snapshot +location of Substrate's bundled object store, and omits `workload` so the release +image is selected. Agents in that namespace can reference them without +platform-specific values. They are ordinary Harnesses owned by the Helm release; +the controller does not create or special-case them. + +The chart renders them only when `harnesses.enabled` is true. Its values +choose the snapshot location and disable individual runtimes. Because +`harnessRef` is namespace-local, Agents in other namespaces still need a Harness +in their own namespace. + +The chart's default OpenAI ModelConfig sets `openAI.apiFormat: responses`. Codex +requires the Responses API, and the kagent runtime supports it, so one default +model serves both default Harnesses. + ### Harness images and workload overrides For built-in kagent, Claude, and Codex Harnesses, the whole `workload` block is @@ -298,6 +317,10 @@ A golden-snapshot failure is retried instead: the controller deletes the failed ActorTemplate and recreates it on the persisted schedule described in [Sandboxes](sandboxes.md#configuration-and-preparation), reporting `Ready=False` with reason `ActorTemplateRetrying` and only the failure reason in its message. +The one allowlisted detail is an image pull failure for an image that the +ActorTemplate itself names: the message then adds `could not pull image `. +The reference comes from the template spec, never from the backend text, so +configuration quoted in an error cannot reach status. ActorTemplate construction checks the size of each emitted environment value against Substrate's 32,768-character limit. Configuration and Agent Card @@ -428,10 +451,11 @@ spec: name: default-model-config systemPrompt: You are a helpful assistant. harnessRef: - name: kagent + name: kagent-default ``` -The referenced ModelConfig and Harness must already exist. +The referenced ModelConfig and Harness must already exist. `kagent install` +creates both `default-model-config` and `kagent-default`. To reuse an existing AgentTemplate, replace the inline template with its reference: @@ -441,7 +465,7 @@ spec: ref: name: shared-context harnessRef: - name: kagent + name: kagent-default ``` Template behavior may be inline or referenced. The Harness is always referenced; diff --git a/docs/architecture/installation.md b/docs/architecture/installation.md index ef30fbc862..0031f73c32 100644 --- a/docs/architecture/installation.md +++ b/docs/architecture/installation.md @@ -8,6 +8,8 @@ Installation has two phases. Planning resolves chart inputs and the controller s The plan installs CRDs before prerequisites, pod certificates, PostgreSQL, Substrate, and kagent. Chart settings and additional database setup are explicit inputs. Command execution and Kubernetes access are injected so tests can exercise the workflow without external services. +The kagent release creates the Substrate WorkerPool and enables the chart's default Harnesses (`kagent-default`, `codex-default`, `claude-default`) on that pool, so a fresh install can run Agents without a hand-written Harness. Operator values files and `--set` overrides can disable them. See [Default Harnesses](configuration-and-compilation.md#default-harnesses). + Model API keys reach Helm through temporary values files with `0600` permissions in private directories. The command runner removes them after each invocation and omits failed-command output that could contain credentials. These files override configured values files while caller `--set` overrides retain their priority. Custom runners must honor `Command.SecretValues` with the same protections. Credentials still become part of Helm release data and Kubernetes Secrets. Uninstall removes resources in the order needed for controllers to finish cleanup. It preserves external databases and the kagent namespace, and it tolerates resources removed by an earlier attempt. A namespace mismatch stops cleanup before deletion. diff --git a/docs/architecture/process-configuration.md b/docs/architecture/process-configuration.md index b5e7d375de..d298b7100f 100644 --- a/docs/architecture/process-configuration.md +++ b/docs/architecture/process-configuration.md @@ -68,6 +68,14 @@ Installation passes embedded defaults before configured chart values files. Files are applied in order, with the last file winning. Provider credentials continue to use the provider SDK environment interface. +`kagent install --provider` overrides `install.defaultModelProvider` for one +install; the protovalidate `in` rule on that field defines the accepted names. +After a successful install the CLI prints next steps. + +`kagent version --client` prints only the CLI build without connecting to the +controller. The `get-kagent` install script uses it to skip reinstalling a +matching version. + ## Controller The controller reads `/etc/kagent/controller.yaml`, or the file selected by diff --git a/docs/architecture/sandboxes.md b/docs/architecture/sandboxes.md index 2a1519996c..0339596ebd 100644 --- a/docs/architecture/sandboxes.md +++ b/docs/architecture/sandboxes.md @@ -115,10 +115,12 @@ computes the retry time from them, and the jitter comes from the failed template's UID, so a controller restart keeps the schedule. While a retry waits, `Ready` is `False` with reason `ActorTemplateRetrying`, and the message names the retry number, its time and the failure reason. Substrate's full error is -logged by the controller, not copied into status, because it can quote -configuration. While the rebuild is pending, the -`Preparing` message names the retry number. A successful build resets the -count. The 1-second poll wakes a waiting retry once its retry time passes; +not copied into status, because it can quote configuration. The only detail +added to the reason is `could not pull image ` when the failure is a pull +of an image that the template's own spec names. The controller takes that +reference from the spec, not from the error text. It logs the full error at +WARN once per failed attempt. While the rebuild is pending, the `Preparing` +message names the retry number. A successful build resets the count. The 1-second poll wakes a waiting retry once its retry time passes; after a failed delete, queue backoff wakes it instead. Sandbox execution is enabled by default. Lifecycle calls run on the serving API diff --git a/go/api/gen/kagent/api/v1/harnesses.pb.go b/go/api/gen/kagent/api/v1/harnesses.pb.go index 2ba7c2dc45..a492ad2ecf 100644 --- a/go/api/gen/kagent/api/v1/harnesses.pb.go +++ b/go/api/gen/kagent/api/v1/harnesses.pb.go @@ -7,6 +7,7 @@ package apiv1 import ( + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" @@ -31,7 +32,8 @@ type Harness struct { // Denormalised because callers listing harnesses group and filter by it, and // would otherwise each reimplement the exactly-one-of check the CRD enforces. Runtime string `protobuf:"bytes,3,opt,name=runtime,proto3" json:"runtime,omitempty"` - // WorkloadImage is spec.workload.image, the digest-pinned runtime image. + // WorkloadImage is spec.workload.image, the digest-pinned runtime image. Empty + // when the spec leaves it to the controller's release default. WorkloadImage string `protobuf:"bytes,4,opt,name=workload_image,json=workloadImage,proto3" json:"workload_image,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -279,6 +281,102 @@ func (x *CreateHarnessResponse) GetHarness() *Harness { return nil } +type UpdateHarnessRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ref *ResourceReference `protobuf:"bytes,1,opt,name=ref,proto3" json:"ref,omitempty"` + Resource *StructuredObject `protobuf:"bytes,2,opt,name=resource,proto3" json:"resource,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateHarnessRequest) Reset() { + *x = UpdateHarnessRequest{} + mi := &file_kagent_api_v1_harnesses_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateHarnessRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateHarnessRequest) ProtoMessage() {} + +func (x *UpdateHarnessRequest) ProtoReflect() protoreflect.Message { + mi := &file_kagent_api_v1_harnesses_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateHarnessRequest.ProtoReflect.Descriptor instead. +func (*UpdateHarnessRequest) Descriptor() ([]byte, []int) { + return file_kagent_api_v1_harnesses_proto_rawDescGZIP(), []int{5} +} + +func (x *UpdateHarnessRequest) GetRef() *ResourceReference { + if x != nil { + return x.Ref + } + return nil +} + +func (x *UpdateHarnessRequest) GetResource() *StructuredObject { + if x != nil { + return x.Resource + } + return nil +} + +type UpdateHarnessResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Harness *Harness `protobuf:"bytes,1,opt,name=harness,proto3" json:"harness,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateHarnessResponse) Reset() { + *x = UpdateHarnessResponse{} + mi := &file_kagent_api_v1_harnesses_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateHarnessResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateHarnessResponse) ProtoMessage() {} + +func (x *UpdateHarnessResponse) ProtoReflect() protoreflect.Message { + mi := &file_kagent_api_v1_harnesses_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateHarnessResponse.ProtoReflect.Descriptor instead. +func (*UpdateHarnessResponse) Descriptor() ([]byte, []int) { + return file_kagent_api_v1_harnesses_proto_rawDescGZIP(), []int{6} +} + +func (x *UpdateHarnessResponse) GetHarness() *Harness { + if x != nil { + return x.Harness + } + return nil +} + type DeleteHarnessRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Ref *ResourceReference `protobuf:"bytes,1,opt,name=ref,proto3" json:"ref,omitempty"` @@ -288,7 +386,7 @@ type DeleteHarnessRequest struct { func (x *DeleteHarnessRequest) Reset() { *x = DeleteHarnessRequest{} - mi := &file_kagent_api_v1_harnesses_proto_msgTypes[5] + mi := &file_kagent_api_v1_harnesses_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -300,7 +398,7 @@ func (x *DeleteHarnessRequest) String() string { func (*DeleteHarnessRequest) ProtoMessage() {} func (x *DeleteHarnessRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_harnesses_proto_msgTypes[5] + mi := &file_kagent_api_v1_harnesses_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -313,7 +411,7 @@ func (x *DeleteHarnessRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteHarnessRequest.ProtoReflect.Descriptor instead. func (*DeleteHarnessRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_harnesses_proto_rawDescGZIP(), []int{5} + return file_kagent_api_v1_harnesses_proto_rawDescGZIP(), []int{7} } func (x *DeleteHarnessRequest) GetRef() *ResourceReference { @@ -331,7 +429,7 @@ type DeleteHarnessResponse struct { func (x *DeleteHarnessResponse) Reset() { *x = DeleteHarnessResponse{} - mi := &file_kagent_api_v1_harnesses_proto_msgTypes[6] + mi := &file_kagent_api_v1_harnesses_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -343,7 +441,7 @@ func (x *DeleteHarnessResponse) String() string { func (*DeleteHarnessResponse) ProtoMessage() {} func (x *DeleteHarnessResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1_harnesses_proto_msgTypes[6] + mi := &file_kagent_api_v1_harnesses_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -356,14 +454,14 @@ func (x *DeleteHarnessResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteHarnessResponse.ProtoReflect.Descriptor instead. func (*DeleteHarnessResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1_harnesses_proto_rawDescGZIP(), []int{6} + return file_kagent_api_v1_harnesses_proto_rawDescGZIP(), []int{8} } var File_kagent_api_v1_harnesses_proto protoreflect.FileDescriptor const file_kagent_api_v1_harnesses_proto_rawDesc = "" + "\n" + - "\x1dkagent/api/v1/harnesses.proto\x12\rkagent.api.v1\x1a\x1akagent/api/v1/common.proto\"\xbb\x01\n" + + "\x1dkagent/api/v1/harnesses.proto\x12\rkagent.api.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1akagent/api/v1/common.proto\"\xbb\x01\n" + "\aHarness\x122\n" + "\x03ref\x18\x01 \x01(\v2 .kagent.api.v1.ResourceReferenceR\x03ref\x12;\n" + "\bresource\x18\x02 \x01(\v2\x1f.kagent.api.v1.StructuredObjectR\bresource\x12\x18\n" + @@ -377,13 +475,19 @@ const file_kagent_api_v1_harnesses_proto_rawDesc = "" + "\x03ref\x18\x01 \x01(\v2 .kagent.api.v1.ResourceReferenceR\x03ref\x12;\n" + "\bresource\x18\x02 \x01(\v2\x1f.kagent.api.v1.StructuredObjectR\bresource\"I\n" + "\x15CreateHarnessResponse\x120\n" + + "\aharness\x18\x01 \x01(\v2\x16.kagent.api.v1.HarnessR\aharness\"\x97\x01\n" + + "\x14UpdateHarnessRequest\x12:\n" + + "\x03ref\x18\x01 \x01(\v2 .kagent.api.v1.ResourceReferenceB\x06\xbaH\x03\xc8\x01\x01R\x03ref\x12C\n" + + "\bresource\x18\x02 \x01(\v2\x1f.kagent.api.v1.StructuredObjectB\x06\xbaH\x03\xc8\x01\x01R\bresource\"I\n" + + "\x15UpdateHarnessResponse\x120\n" + "\aharness\x18\x01 \x01(\v2\x16.kagent.api.v1.HarnessR\aharness\"J\n" + "\x14DeleteHarnessRequest\x122\n" + "\x03ref\x18\x01 \x01(\v2 .kagent.api.v1.ResourceReferenceR\x03ref\"\x17\n" + - "\x15DeleteHarnessResponse2\xa4\x02\n" + + "\x15DeleteHarnessResponse2\x80\x03\n" + "\x0eHarnessService\x12Z\n" + "\rListHarnesses\x12#.kagent.api.v1.ListHarnessesRequest\x1a$.kagent.api.v1.ListHarnessesResponse\x12Z\n" + "\rCreateHarness\x12#.kagent.api.v1.CreateHarnessRequest\x1a$.kagent.api.v1.CreateHarnessResponse\x12Z\n" + + "\rUpdateHarness\x12#.kagent.api.v1.UpdateHarnessRequest\x1a$.kagent.api.v1.UpdateHarnessResponse\x12Z\n" + "\rDeleteHarness\x12#.kagent.api.v1.DeleteHarnessRequest\x1a$.kagent.api.v1.DeleteHarnessResponseB=Z;github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1;apiv1b\x06proto3" var ( @@ -398,37 +502,44 @@ func file_kagent_api_v1_harnesses_proto_rawDescGZIP() []byte { return file_kagent_api_v1_harnesses_proto_rawDescData } -var file_kagent_api_v1_harnesses_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_kagent_api_v1_harnesses_proto_msgTypes = make([]protoimpl.MessageInfo, 9) var file_kagent_api_v1_harnesses_proto_goTypes = []any{ (*Harness)(nil), // 0: kagent.api.v1.Harness (*ListHarnessesRequest)(nil), // 1: kagent.api.v1.ListHarnessesRequest (*ListHarnessesResponse)(nil), // 2: kagent.api.v1.ListHarnessesResponse (*CreateHarnessRequest)(nil), // 3: kagent.api.v1.CreateHarnessRequest (*CreateHarnessResponse)(nil), // 4: kagent.api.v1.CreateHarnessResponse - (*DeleteHarnessRequest)(nil), // 5: kagent.api.v1.DeleteHarnessRequest - (*DeleteHarnessResponse)(nil), // 6: kagent.api.v1.DeleteHarnessResponse - (*ResourceReference)(nil), // 7: kagent.api.v1.ResourceReference - (*StructuredObject)(nil), // 8: kagent.api.v1.StructuredObject + (*UpdateHarnessRequest)(nil), // 5: kagent.api.v1.UpdateHarnessRequest + (*UpdateHarnessResponse)(nil), // 6: kagent.api.v1.UpdateHarnessResponse + (*DeleteHarnessRequest)(nil), // 7: kagent.api.v1.DeleteHarnessRequest + (*DeleteHarnessResponse)(nil), // 8: kagent.api.v1.DeleteHarnessResponse + (*ResourceReference)(nil), // 9: kagent.api.v1.ResourceReference + (*StructuredObject)(nil), // 10: kagent.api.v1.StructuredObject } var file_kagent_api_v1_harnesses_proto_depIdxs = []int32{ - 7, // 0: kagent.api.v1.Harness.ref:type_name -> kagent.api.v1.ResourceReference - 8, // 1: kagent.api.v1.Harness.resource:type_name -> kagent.api.v1.StructuredObject + 9, // 0: kagent.api.v1.Harness.ref:type_name -> kagent.api.v1.ResourceReference + 10, // 1: kagent.api.v1.Harness.resource:type_name -> kagent.api.v1.StructuredObject 0, // 2: kagent.api.v1.ListHarnessesResponse.harnesses:type_name -> kagent.api.v1.Harness - 7, // 3: kagent.api.v1.CreateHarnessRequest.ref:type_name -> kagent.api.v1.ResourceReference - 8, // 4: kagent.api.v1.CreateHarnessRequest.resource:type_name -> kagent.api.v1.StructuredObject + 9, // 3: kagent.api.v1.CreateHarnessRequest.ref:type_name -> kagent.api.v1.ResourceReference + 10, // 4: kagent.api.v1.CreateHarnessRequest.resource:type_name -> kagent.api.v1.StructuredObject 0, // 5: kagent.api.v1.CreateHarnessResponse.harness:type_name -> kagent.api.v1.Harness - 7, // 6: kagent.api.v1.DeleteHarnessRequest.ref:type_name -> kagent.api.v1.ResourceReference - 1, // 7: kagent.api.v1.HarnessService.ListHarnesses:input_type -> kagent.api.v1.ListHarnessesRequest - 3, // 8: kagent.api.v1.HarnessService.CreateHarness:input_type -> kagent.api.v1.CreateHarnessRequest - 5, // 9: kagent.api.v1.HarnessService.DeleteHarness:input_type -> kagent.api.v1.DeleteHarnessRequest - 2, // 10: kagent.api.v1.HarnessService.ListHarnesses:output_type -> kagent.api.v1.ListHarnessesResponse - 4, // 11: kagent.api.v1.HarnessService.CreateHarness:output_type -> kagent.api.v1.CreateHarnessResponse - 6, // 12: kagent.api.v1.HarnessService.DeleteHarness:output_type -> kagent.api.v1.DeleteHarnessResponse - 10, // [10:13] is the sub-list for method output_type - 7, // [7:10] is the sub-list for method input_type - 7, // [7:7] is the sub-list for extension type_name - 7, // [7:7] is the sub-list for extension extendee - 0, // [0:7] is the sub-list for field type_name + 9, // 6: kagent.api.v1.UpdateHarnessRequest.ref:type_name -> kagent.api.v1.ResourceReference + 10, // 7: kagent.api.v1.UpdateHarnessRequest.resource:type_name -> kagent.api.v1.StructuredObject + 0, // 8: kagent.api.v1.UpdateHarnessResponse.harness:type_name -> kagent.api.v1.Harness + 9, // 9: kagent.api.v1.DeleteHarnessRequest.ref:type_name -> kagent.api.v1.ResourceReference + 1, // 10: kagent.api.v1.HarnessService.ListHarnesses:input_type -> kagent.api.v1.ListHarnessesRequest + 3, // 11: kagent.api.v1.HarnessService.CreateHarness:input_type -> kagent.api.v1.CreateHarnessRequest + 5, // 12: kagent.api.v1.HarnessService.UpdateHarness:input_type -> kagent.api.v1.UpdateHarnessRequest + 7, // 13: kagent.api.v1.HarnessService.DeleteHarness:input_type -> kagent.api.v1.DeleteHarnessRequest + 2, // 14: kagent.api.v1.HarnessService.ListHarnesses:output_type -> kagent.api.v1.ListHarnessesResponse + 4, // 15: kagent.api.v1.HarnessService.CreateHarness:output_type -> kagent.api.v1.CreateHarnessResponse + 6, // 16: kagent.api.v1.HarnessService.UpdateHarness:output_type -> kagent.api.v1.UpdateHarnessResponse + 8, // 17: kagent.api.v1.HarnessService.DeleteHarness:output_type -> kagent.api.v1.DeleteHarnessResponse + 14, // [14:18] is the sub-list for method output_type + 10, // [10:14] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_kagent_api_v1_harnesses_proto_init() } @@ -443,7 +554,7 @@ func file_kagent_api_v1_harnesses_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_kagent_api_v1_harnesses_proto_rawDesc), len(file_kagent_api_v1_harnesses_proto_rawDesc)), NumEnums: 0, - NumMessages: 7, + NumMessages: 9, NumExtensions: 0, NumServices: 1, }, diff --git a/go/api/gen/kagent/api/v1/harnesses_grpc.pb.go b/go/api/gen/kagent/api/v1/harnesses_grpc.pb.go index 01d5d54211..ea1996e5c3 100644 --- a/go/api/gen/kagent/api/v1/harnesses_grpc.pb.go +++ b/go/api/gen/kagent/api/v1/harnesses_grpc.pb.go @@ -21,6 +21,7 @@ const _ = grpc.SupportPackageIsVersion9 const ( HarnessService_ListHarnesses_FullMethodName = "/kagent.api.v1.HarnessService/ListHarnesses" HarnessService_CreateHarness_FullMethodName = "/kagent.api.v1.HarnessService/CreateHarness" + HarnessService_UpdateHarness_FullMethodName = "/kagent.api.v1.HarnessService/UpdateHarness" HarnessService_DeleteHarness_FullMethodName = "/kagent.api.v1.HarnessService/DeleteHarness" ) @@ -34,6 +35,9 @@ const ( type HarnessServiceClient interface { ListHarnesses(ctx context.Context, in *ListHarnessesRequest, opts ...grpc.CallOption) (*ListHarnessesResponse, error) CreateHarness(ctx context.Context, in *CreateHarnessRequest, opts ...grpc.CallOption) (*CreateHarnessResponse, error) + // UpdateHarness replaces the spec and labels of an existing Harness. Agents + // that reference it pick up the change on their next reconcile. + UpdateHarness(ctx context.Context, in *UpdateHarnessRequest, opts ...grpc.CallOption) (*UpdateHarnessResponse, error) DeleteHarness(ctx context.Context, in *DeleteHarnessRequest, opts ...grpc.CallOption) (*DeleteHarnessResponse, error) } @@ -65,6 +69,16 @@ func (c *harnessServiceClient) CreateHarness(ctx context.Context, in *CreateHarn return out, nil } +func (c *harnessServiceClient) UpdateHarness(ctx context.Context, in *UpdateHarnessRequest, opts ...grpc.CallOption) (*UpdateHarnessResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UpdateHarnessResponse) + err := c.cc.Invoke(ctx, HarnessService_UpdateHarness_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *harnessServiceClient) DeleteHarness(ctx context.Context, in *DeleteHarnessRequest, opts ...grpc.CallOption) (*DeleteHarnessResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(DeleteHarnessResponse) @@ -85,6 +99,9 @@ func (c *harnessServiceClient) DeleteHarness(ctx context.Context, in *DeleteHarn type HarnessServiceServer interface { ListHarnesses(context.Context, *ListHarnessesRequest) (*ListHarnessesResponse, error) CreateHarness(context.Context, *CreateHarnessRequest) (*CreateHarnessResponse, error) + // UpdateHarness replaces the spec and labels of an existing Harness. Agents + // that reference it pick up the change on their next reconcile. + UpdateHarness(context.Context, *UpdateHarnessRequest) (*UpdateHarnessResponse, error) DeleteHarness(context.Context, *DeleteHarnessRequest) (*DeleteHarnessResponse, error) mustEmbedUnimplementedHarnessServiceServer() } @@ -102,6 +119,9 @@ func (UnimplementedHarnessServiceServer) ListHarnesses(context.Context, *ListHar func (UnimplementedHarnessServiceServer) CreateHarness(context.Context, *CreateHarnessRequest) (*CreateHarnessResponse, error) { return nil, status.Error(codes.Unimplemented, "method CreateHarness not implemented") } +func (UnimplementedHarnessServiceServer) UpdateHarness(context.Context, *UpdateHarnessRequest) (*UpdateHarnessResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateHarness not implemented") +} func (UnimplementedHarnessServiceServer) DeleteHarness(context.Context, *DeleteHarnessRequest) (*DeleteHarnessResponse, error) { return nil, status.Error(codes.Unimplemented, "method DeleteHarness not implemented") } @@ -162,6 +182,24 @@ func _HarnessService_CreateHarness_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } +func _HarnessService_UpdateHarness_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateHarnessRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HarnessServiceServer).UpdateHarness(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: HarnessService_UpdateHarness_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HarnessServiceServer).UpdateHarness(ctx, req.(*UpdateHarnessRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _HarnessService_DeleteHarness_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(DeleteHarnessRequest) if err := dec(in); err != nil { @@ -195,6 +233,10 @@ var HarnessService_ServiceDesc = grpc.ServiceDesc{ MethodName: "CreateHarness", Handler: _HarnessService_CreateHarness_Handler, }, + { + MethodName: "UpdateHarness", + Handler: _HarnessService_UpdateHarness_Handler, + }, { MethodName: "DeleteHarness", Handler: _HarnessService_DeleteHarness_Handler, diff --git a/go/api/gen/kagent/api/v1/system.pb.go b/go/api/gen/kagent/api/v1/system.pb.go index cb23e602f6..a497d1a980 100644 --- a/go/api/gen/kagent/api/v1/system.pb.go +++ b/go/api/gen/kagent/api/v1/system.pb.go @@ -865,6 +865,96 @@ func (x *ListSubstrateWorkersResponse) GetComputedAt() *timestamppb.Timestamp { return nil } +type ListWorkerPoolsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Kubernetes namespace of the pools. Empty uses watched namespaces. + Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListWorkerPoolsRequest) Reset() { + *x = ListWorkerPoolsRequest{} + mi := &file_kagent_api_v1_system_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListWorkerPoolsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListWorkerPoolsRequest) ProtoMessage() {} + +func (x *ListWorkerPoolsRequest) ProtoReflect() protoreflect.Message { + mi := &file_kagent_api_v1_system_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListWorkerPoolsRequest.ProtoReflect.Descriptor instead. +func (*ListWorkerPoolsRequest) Descriptor() ([]byte, []int) { + return file_kagent_api_v1_system_proto_rawDescGZIP(), []int{15} +} + +func (x *ListWorkerPoolsRequest) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +type ListWorkerPoolsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Pools the caller may read, in namespace/name order. + WorkerPools []*SubstrateWorkerPool `protobuf:"bytes,1,rep,name=worker_pools,json=workerPools,proto3" json:"worker_pools,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListWorkerPoolsResponse) Reset() { + *x = ListWorkerPoolsResponse{} + mi := &file_kagent_api_v1_system_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListWorkerPoolsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListWorkerPoolsResponse) ProtoMessage() {} + +func (x *ListWorkerPoolsResponse) ProtoReflect() protoreflect.Message { + mi := &file_kagent_api_v1_system_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListWorkerPoolsResponse.ProtoReflect.Descriptor instead. +func (*ListWorkerPoolsResponse) Descriptor() ([]byte, []int) { + return file_kagent_api_v1_system_proto_rawDescGZIP(), []int{16} +} + +func (x *ListWorkerPoolsResponse) GetWorkerPools() []*SubstrateWorkerPool { + if x != nil { + return x.WorkerPools + } + return nil +} + var File_kagent_api_v1_system_proto protoreflect.FileDescriptor const file_kagent_api_v1_system_proto_rawDesc = "" + @@ -927,7 +1017,11 @@ const file_kagent_api_v1_system_proto_rawDesc = "" + "\aworkers\x18\x03 \x03(\v2\x1b.substrate.ateapi.v1.WorkerR\aworkers\x12/\n" + "\x04page\x18\x04 \x01(\v2\x1b.kagent.api.v1.PageResponseR\x04page\x12;\n" + "\vcomputed_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "computedAt2\xed\x04\n" + + "computedAt\"c\n" + + "\x16ListWorkerPoolsRequest\x12I\n" + + "\tnamespace\x18\x01 \x01(\tB+\xbaH(r&\x18?2\"^([a-z0-9]([-a-z0-9]*[a-z0-9])?)?$R\tnamespace\"`\n" + + "\x17ListWorkerPoolsResponse\x12E\n" + + "\fworker_pools\x18\x01 \x03(\v2\".kagent.api.v1.SubstrateWorkerPoolR\vworkerPools2\xcf\x05\n" + "\rSystemService\x12Q\n" + "\n" + "GetVersion\x12 .kagent.api.v1.GetVersionRequest\x1a!.kagent.api.v1.GetVersionResponse\x12]\n" + @@ -935,7 +1029,8 @@ const file_kagent_api_v1_system_proto_rawDesc = "" + "\x0eListNamespaces\x12$.kagent.api.v1.ListNamespacesRequest\x1a%.kagent.api.v1.ListNamespacesResponse\x12l\n" + "\x13GetSubstrateSummary\x12).kagent.api.v1.GetSubstrateSummaryRequest\x1a*.kagent.api.v1.GetSubstrateSummaryResponse\x12l\n" + "\x13ListSubstrateActors\x12).kagent.api.v1.ListSubstrateActorsRequest\x1a*.kagent.api.v1.ListSubstrateActorsResponse\x12o\n" + - "\x14ListSubstrateWorkers\x12*.kagent.api.v1.ListSubstrateWorkersRequest\x1a+.kagent.api.v1.ListSubstrateWorkersResponseB=Z;github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1;apiv1b\x06proto3" + "\x14ListSubstrateWorkers\x12*.kagent.api.v1.ListSubstrateWorkersRequest\x1a+.kagent.api.v1.ListSubstrateWorkersResponse\x12`\n" + + "\x0fListWorkerPools\x12%.kagent.api.v1.ListWorkerPoolsRequest\x1a&.kagent.api.v1.ListWorkerPoolsResponseB=Z;github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1;apiv1b\x06proto3" var ( file_kagent_api_v1_system_proto_rawDescOnce sync.Once @@ -949,7 +1044,7 @@ func file_kagent_api_v1_system_proto_rawDescGZIP() []byte { return file_kagent_api_v1_system_proto_rawDescData } -var file_kagent_api_v1_system_proto_msgTypes = make([]protoimpl.MessageInfo, 15) +var file_kagent_api_v1_system_proto_msgTypes = make([]protoimpl.MessageInfo, 17) var file_kagent_api_v1_system_proto_goTypes = []any{ (*GetVersionRequest)(nil), // 0: kagent.api.v1.GetVersionRequest (*GetVersionResponse)(nil), // 1: kagent.api.v1.GetVersionResponse @@ -966,52 +1061,57 @@ var file_kagent_api_v1_system_proto_goTypes = []any{ (*ListSubstrateActorsResponse)(nil), // 12: kagent.api.v1.ListSubstrateActorsResponse (*ListSubstrateWorkersRequest)(nil), // 13: kagent.api.v1.ListSubstrateWorkersRequest (*ListSubstrateWorkersResponse)(nil), // 14: kagent.api.v1.ListSubstrateWorkersResponse - (*structpb.Struct)(nil), // 15: google.protobuf.Struct - (*ResourceReference)(nil), // 16: kagent.api.v1.ResourceReference - (*StructuredObject)(nil), // 17: kagent.api.v1.StructuredObject - (v1.ActorState)(0), // 18: substrate.ateapi.v1.ActorState - (*timestamppb.Timestamp)(nil), // 19: google.protobuf.Timestamp - (*v1.ActorTemplate)(nil), // 20: substrate.ateapi.v1.ActorTemplate - (*PageRequest)(nil), // 21: kagent.api.v1.PageRequest - (*v1.Actor)(nil), // 22: substrate.ateapi.v1.Actor - (*PageResponse)(nil), // 23: kagent.api.v1.PageResponse - (*v1.Worker)(nil), // 24: substrate.ateapi.v1.Worker + (*ListWorkerPoolsRequest)(nil), // 15: kagent.api.v1.ListWorkerPoolsRequest + (*ListWorkerPoolsResponse)(nil), // 16: kagent.api.v1.ListWorkerPoolsResponse + (*structpb.Struct)(nil), // 17: google.protobuf.Struct + (*ResourceReference)(nil), // 18: kagent.api.v1.ResourceReference + (*StructuredObject)(nil), // 19: kagent.api.v1.StructuredObject + (v1.ActorState)(0), // 20: substrate.ateapi.v1.ActorState + (*timestamppb.Timestamp)(nil), // 21: google.protobuf.Timestamp + (*v1.ActorTemplate)(nil), // 22: substrate.ateapi.v1.ActorTemplate + (*PageRequest)(nil), // 23: kagent.api.v1.PageRequest + (*v1.Actor)(nil), // 24: substrate.ateapi.v1.Actor + (*PageResponse)(nil), // 25: kagent.api.v1.PageResponse + (*v1.Worker)(nil), // 26: substrate.ateapi.v1.Worker } var file_kagent_api_v1_system_proto_depIdxs = []int32{ - 15, // 0: kagent.api.v1.GetCurrentUserResponse.claims:type_name -> google.protobuf.Struct + 17, // 0: kagent.api.v1.GetCurrentUserResponse.claims:type_name -> google.protobuf.Struct 5, // 1: kagent.api.v1.ListNamespacesResponse.namespaces:type_name -> kagent.api.v1.Namespace - 16, // 2: kagent.api.v1.SubstrateWorkerPool.ref:type_name -> kagent.api.v1.ResourceReference - 17, // 3: kagent.api.v1.SubstrateWorkerPool.resource:type_name -> kagent.api.v1.StructuredObject - 18, // 4: kagent.api.v1.SubstrateActorStatusCount.state:type_name -> substrate.ateapi.v1.ActorState + 18, // 2: kagent.api.v1.SubstrateWorkerPool.ref:type_name -> kagent.api.v1.ResourceReference + 19, // 3: kagent.api.v1.SubstrateWorkerPool.resource:type_name -> kagent.api.v1.StructuredObject + 20, // 4: kagent.api.v1.SubstrateActorStatusCount.state:type_name -> substrate.ateapi.v1.ActorState 7, // 5: kagent.api.v1.GetSubstrateSummaryResponse.worker_pools:type_name -> kagent.api.v1.SubstrateWorkerPool 9, // 6: kagent.api.v1.GetSubstrateSummaryResponse.actor_status_counts:type_name -> kagent.api.v1.SubstrateActorStatusCount - 19, // 7: kagent.api.v1.GetSubstrateSummaryResponse.computed_at:type_name -> google.protobuf.Timestamp - 20, // 8: kagent.api.v1.GetSubstrateSummaryResponse.actor_templates:type_name -> substrate.ateapi.v1.ActorTemplate - 21, // 9: kagent.api.v1.ListSubstrateActorsRequest.page:type_name -> kagent.api.v1.PageRequest - 22, // 10: kagent.api.v1.ListSubstrateActorsResponse.actors:type_name -> substrate.ateapi.v1.Actor - 23, // 11: kagent.api.v1.ListSubstrateActorsResponse.page:type_name -> kagent.api.v1.PageResponse - 19, // 12: kagent.api.v1.ListSubstrateActorsResponse.computed_at:type_name -> google.protobuf.Timestamp - 21, // 13: kagent.api.v1.ListSubstrateWorkersRequest.page:type_name -> kagent.api.v1.PageRequest - 24, // 14: kagent.api.v1.ListSubstrateWorkersResponse.workers:type_name -> substrate.ateapi.v1.Worker - 23, // 15: kagent.api.v1.ListSubstrateWorkersResponse.page:type_name -> kagent.api.v1.PageResponse - 19, // 16: kagent.api.v1.ListSubstrateWorkersResponse.computed_at:type_name -> google.protobuf.Timestamp - 0, // 17: kagent.api.v1.SystemService.GetVersion:input_type -> kagent.api.v1.GetVersionRequest - 2, // 18: kagent.api.v1.SystemService.GetCurrentUser:input_type -> kagent.api.v1.GetCurrentUserRequest - 4, // 19: kagent.api.v1.SystemService.ListNamespaces:input_type -> kagent.api.v1.ListNamespacesRequest - 8, // 20: kagent.api.v1.SystemService.GetSubstrateSummary:input_type -> kagent.api.v1.GetSubstrateSummaryRequest - 11, // 21: kagent.api.v1.SystemService.ListSubstrateActors:input_type -> kagent.api.v1.ListSubstrateActorsRequest - 13, // 22: kagent.api.v1.SystemService.ListSubstrateWorkers:input_type -> kagent.api.v1.ListSubstrateWorkersRequest - 1, // 23: kagent.api.v1.SystemService.GetVersion:output_type -> kagent.api.v1.GetVersionResponse - 3, // 24: kagent.api.v1.SystemService.GetCurrentUser:output_type -> kagent.api.v1.GetCurrentUserResponse - 6, // 25: kagent.api.v1.SystemService.ListNamespaces:output_type -> kagent.api.v1.ListNamespacesResponse - 10, // 26: kagent.api.v1.SystemService.GetSubstrateSummary:output_type -> kagent.api.v1.GetSubstrateSummaryResponse - 12, // 27: kagent.api.v1.SystemService.ListSubstrateActors:output_type -> kagent.api.v1.ListSubstrateActorsResponse - 14, // 28: kagent.api.v1.SystemService.ListSubstrateWorkers:output_type -> kagent.api.v1.ListSubstrateWorkersResponse - 23, // [23:29] is the sub-list for method output_type - 17, // [17:23] is the sub-list for method input_type - 17, // [17:17] is the sub-list for extension type_name - 17, // [17:17] is the sub-list for extension extendee - 0, // [0:17] is the sub-list for field type_name + 21, // 7: kagent.api.v1.GetSubstrateSummaryResponse.computed_at:type_name -> google.protobuf.Timestamp + 22, // 8: kagent.api.v1.GetSubstrateSummaryResponse.actor_templates:type_name -> substrate.ateapi.v1.ActorTemplate + 23, // 9: kagent.api.v1.ListSubstrateActorsRequest.page:type_name -> kagent.api.v1.PageRequest + 24, // 10: kagent.api.v1.ListSubstrateActorsResponse.actors:type_name -> substrate.ateapi.v1.Actor + 25, // 11: kagent.api.v1.ListSubstrateActorsResponse.page:type_name -> kagent.api.v1.PageResponse + 21, // 12: kagent.api.v1.ListSubstrateActorsResponse.computed_at:type_name -> google.protobuf.Timestamp + 23, // 13: kagent.api.v1.ListSubstrateWorkersRequest.page:type_name -> kagent.api.v1.PageRequest + 26, // 14: kagent.api.v1.ListSubstrateWorkersResponse.workers:type_name -> substrate.ateapi.v1.Worker + 25, // 15: kagent.api.v1.ListSubstrateWorkersResponse.page:type_name -> kagent.api.v1.PageResponse + 21, // 16: kagent.api.v1.ListSubstrateWorkersResponse.computed_at:type_name -> google.protobuf.Timestamp + 7, // 17: kagent.api.v1.ListWorkerPoolsResponse.worker_pools:type_name -> kagent.api.v1.SubstrateWorkerPool + 0, // 18: kagent.api.v1.SystemService.GetVersion:input_type -> kagent.api.v1.GetVersionRequest + 2, // 19: kagent.api.v1.SystemService.GetCurrentUser:input_type -> kagent.api.v1.GetCurrentUserRequest + 4, // 20: kagent.api.v1.SystemService.ListNamespaces:input_type -> kagent.api.v1.ListNamespacesRequest + 8, // 21: kagent.api.v1.SystemService.GetSubstrateSummary:input_type -> kagent.api.v1.GetSubstrateSummaryRequest + 11, // 22: kagent.api.v1.SystemService.ListSubstrateActors:input_type -> kagent.api.v1.ListSubstrateActorsRequest + 13, // 23: kagent.api.v1.SystemService.ListSubstrateWorkers:input_type -> kagent.api.v1.ListSubstrateWorkersRequest + 15, // 24: kagent.api.v1.SystemService.ListWorkerPools:input_type -> kagent.api.v1.ListWorkerPoolsRequest + 1, // 25: kagent.api.v1.SystemService.GetVersion:output_type -> kagent.api.v1.GetVersionResponse + 3, // 26: kagent.api.v1.SystemService.GetCurrentUser:output_type -> kagent.api.v1.GetCurrentUserResponse + 6, // 27: kagent.api.v1.SystemService.ListNamespaces:output_type -> kagent.api.v1.ListNamespacesResponse + 10, // 28: kagent.api.v1.SystemService.GetSubstrateSummary:output_type -> kagent.api.v1.GetSubstrateSummaryResponse + 12, // 29: kagent.api.v1.SystemService.ListSubstrateActors:output_type -> kagent.api.v1.ListSubstrateActorsResponse + 14, // 30: kagent.api.v1.SystemService.ListSubstrateWorkers:output_type -> kagent.api.v1.ListSubstrateWorkersResponse + 16, // 31: kagent.api.v1.SystemService.ListWorkerPools:output_type -> kagent.api.v1.ListWorkerPoolsResponse + 25, // [25:32] is the sub-list for method output_type + 18, // [18:25] is the sub-list for method input_type + 18, // [18:18] is the sub-list for extension type_name + 18, // [18:18] is the sub-list for extension extendee + 0, // [0:18] is the sub-list for field type_name } func init() { file_kagent_api_v1_system_proto_init() } @@ -1026,7 +1126,7 @@ func file_kagent_api_v1_system_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_kagent_api_v1_system_proto_rawDesc), len(file_kagent_api_v1_system_proto_rawDesc)), NumEnums: 0, - NumMessages: 15, + NumMessages: 17, NumExtensions: 0, NumServices: 1, }, diff --git a/go/api/gen/kagent/api/v1/system_grpc.pb.go b/go/api/gen/kagent/api/v1/system_grpc.pb.go index d3c4a9b7b3..0ea7041a94 100644 --- a/go/api/gen/kagent/api/v1/system_grpc.pb.go +++ b/go/api/gen/kagent/api/v1/system_grpc.pb.go @@ -25,6 +25,7 @@ const ( SystemService_GetSubstrateSummary_FullMethodName = "/kagent.api.v1.SystemService/GetSubstrateSummary" SystemService_ListSubstrateActors_FullMethodName = "/kagent.api.v1.SystemService/ListSubstrateActors" SystemService_ListSubstrateWorkers_FullMethodName = "/kagent.api.v1.SystemService/ListSubstrateWorkers" + SystemService_ListWorkerPools_FullMethodName = "/kagent.api.v1.SystemService/ListWorkerPools" ) // SystemServiceClient is the client API for SystemService service. @@ -37,6 +38,10 @@ type SystemServiceClient interface { GetSubstrateSummary(ctx context.Context, in *GetSubstrateSummaryRequest, opts ...grpc.CallOption) (*GetSubstrateSummaryResponse, error) ListSubstrateActors(ctx context.Context, in *ListSubstrateActorsRequest, opts ...grpc.CallOption) (*ListSubstrateActorsResponse, error) ListSubstrateWorkers(ctx context.Context, in *ListSubstrateWorkersRequest, opts ...grpc.CallOption) (*ListSubstrateWorkersResponse, error) + // ListWorkerPools reads WorkerPool resources from Kubernetes only. A Harness + // names a pool in its own namespace, so forms use this rather than the summary, + // which walks every ate-api actor and worker. + ListWorkerPools(ctx context.Context, in *ListWorkerPoolsRequest, opts ...grpc.CallOption) (*ListWorkerPoolsResponse, error) } type systemServiceClient struct { @@ -107,6 +112,16 @@ func (c *systemServiceClient) ListSubstrateWorkers(ctx context.Context, in *List return out, nil } +func (c *systemServiceClient) ListWorkerPools(ctx context.Context, in *ListWorkerPoolsRequest, opts ...grpc.CallOption) (*ListWorkerPoolsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListWorkerPoolsResponse) + err := c.cc.Invoke(ctx, SystemService_ListWorkerPools_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // SystemServiceServer is the server API for SystemService service. // All implementations must embed UnimplementedSystemServiceServer // for forward compatibility. @@ -117,6 +132,10 @@ type SystemServiceServer interface { GetSubstrateSummary(context.Context, *GetSubstrateSummaryRequest) (*GetSubstrateSummaryResponse, error) ListSubstrateActors(context.Context, *ListSubstrateActorsRequest) (*ListSubstrateActorsResponse, error) ListSubstrateWorkers(context.Context, *ListSubstrateWorkersRequest) (*ListSubstrateWorkersResponse, error) + // ListWorkerPools reads WorkerPool resources from Kubernetes only. A Harness + // names a pool in its own namespace, so forms use this rather than the summary, + // which walks every ate-api actor and worker. + ListWorkerPools(context.Context, *ListWorkerPoolsRequest) (*ListWorkerPoolsResponse, error) mustEmbedUnimplementedSystemServiceServer() } @@ -145,6 +164,9 @@ func (UnimplementedSystemServiceServer) ListSubstrateActors(context.Context, *Li func (UnimplementedSystemServiceServer) ListSubstrateWorkers(context.Context, *ListSubstrateWorkersRequest) (*ListSubstrateWorkersResponse, error) { return nil, status.Error(codes.Unimplemented, "method ListSubstrateWorkers not implemented") } +func (UnimplementedSystemServiceServer) ListWorkerPools(context.Context, *ListWorkerPoolsRequest) (*ListWorkerPoolsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListWorkerPools not implemented") +} func (UnimplementedSystemServiceServer) mustEmbedUnimplementedSystemServiceServer() {} func (UnimplementedSystemServiceServer) testEmbeddedByValue() {} @@ -274,6 +296,24 @@ func _SystemService_ListSubstrateWorkers_Handler(srv interface{}, ctx context.Co return interceptor(ctx, in, info, handler) } +func _SystemService_ListWorkerPools_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListWorkerPoolsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SystemServiceServer).ListWorkerPools(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SystemService_ListWorkerPools_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SystemServiceServer).ListWorkerPools(ctx, req.(*ListWorkerPoolsRequest)) + } + return interceptor(ctx, in, info, handler) +} + // SystemService_ServiceDesc is the grpc.ServiceDesc for SystemService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -305,6 +345,10 @@ var SystemService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ListSubstrateWorkers", Handler: _SystemService_ListSubstrateWorkers_Handler, }, + { + MethodName: "ListWorkerPools", + Handler: _SystemService_ListWorkerPools_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "kagent/api/v1/system.proto", diff --git a/go/core/cli/install_dry_run_test.go b/go/core/cli/install_dry_run_test.go index 69fd0d8cc8..6e527e3ad5 100644 --- a/go/core/cli/install_dry_run_test.go +++ b/go/core/cli/install_dry_run_test.go @@ -126,3 +126,31 @@ func TestDryRunFlagsAreIndependent(t *testing.T) { require.Equal(t, "kagent-install", secondInstall.Flags().Lookup("output-dir").Value.String()) require.Equal(t, "false", secondInstall.Flags().Lookup("dry-run").Value.String()) } + +func TestInstallProviderFlag(t *testing.T) { + // Helm splits -f arguments on commas, which sentence-named subtest + // directories contain, so the charts and config live in the parent test directory. + config := installConfigWithLocalCharts(t) + t.Run("Given a config defaulting to OpenAI, when a dry run selects --provider Anthropic, assert that the export uses the Anthropic provider and credential", func(t *testing.T) { + if _, err := exec.LookPath("helm"); err != nil { + t.Skip("Helm is required for dry-run integration") + } + t.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "missing-kubeconfig")) + destination := filepath.Join(t.TempDir(), "export") + _, err := executeConfig(t, "--config", config, "install", "--dry-run", "--provider", "Anthropic", "--output-dir", destination) + require.NoError(t, err) + values, err := os.ReadFile(filepath.Join(destination, "values", "kagent.yaml")) + require.NoError(t, err) + require.Contains(t, string(values), "default: anthropic") + require.Contains(t, string(values), "${ANTHROPIC_API_KEY}") + require.NotContains(t, string(values), "${OPENAI_API_KEY}") + }) + + t.Run("Given a valid config, when install runs with an unknown --provider, assert that it is rejected with the allowed providers", func(t *testing.T) { + destination := filepath.Join(t.TempDir(), "export") + _, err := executeConfig(t, "--config", config, "install", "--dry-run", "--provider", "Unknown", "--output-dir", destination) + require.ErrorContains(t, err, `invalid --provider "Unknown": must be one of OpenAI, Anthropic`) + _, statErr := os.Stat(destination) + require.True(t, os.IsNotExist(statErr), "a rejected provider must not export anything") + }) +} diff --git a/go/core/cli/internal/commands/install.go b/go/core/cli/internal/commands/install.go index 55f6296e9b..2e2f0ac4d2 100644 --- a/go/core/cli/internal/commands/install.go +++ b/go/core/cli/internal/commands/install.go @@ -4,6 +4,7 @@ import ( _ "embed" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -11,8 +12,10 @@ import ( "strings" "time" + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" "github.com/briandowns/spinner" "github.com/spf13/cobra" + "google.golang.org/protobuf/proto" "k8s.io/client-go/kubernetes" "sigs.k8s.io/controller-runtime/pkg/client/config" @@ -37,6 +40,8 @@ type InstallCfg struct { SkipDatabaseSetup bool DryRun bool OutputDir string + // Provider overrides install.defaultModelProvider when set. + Provider string } func newInstallClient() (*installer.Client, error) { @@ -65,7 +70,10 @@ func runInstall(cmd *cobra.Command, options connection.Options, cfg *InstallCfg) return errors.New("installation requires a released version of kagent") } settings := cliconfig.FromContext(cmd.Context()).GetInstall() - modelProvider := v1alpha3.ModelProvider(settings.GetDefaultModelProvider()) + modelProvider, err := installModelProvider(settings.GetDefaultModelProvider(), cfg.Provider) + if err != nil { + return err + } apiKeyValue := "" apiKeyEnvironment := "" if apiKey, ok := providerAPIKey(modelProvider); ok { @@ -74,7 +82,7 @@ func runInstall(cmd *cobra.Command, options connection.Options, cfg *InstallCfg) } else { apiKeyValue = apiKey.Get() if apiKeyValue == "" { - return fmt.Errorf("%s is not set; set it, or choose another provider with install.defaultModelProvider in the CLI config", apiKey.Name()) + return fmt.Errorf("%s is not set; set it, or choose another provider with --provider or install.defaultModelProvider in the CLI config", apiKey.Name()) } } } @@ -149,7 +157,38 @@ func runInstall(cmd *cobra.Command, options connection.Options, cfg *InstallCfg) return fmt.Errorf("start port-forward: %w", err) } pf.Stop() - return nil + return printInstallNextSteps(cmd.OutOrStdout(), options.Namespace) +} + +// printInstallNextSteps tells a new user how to reach kagent after install. +func printInstallNextSteps(out io.Writer, namespace string) error { + _, err := fmt.Fprintf(out, ` +Next steps: + Open the UI: kagent dashboard + Create an agent: in the UI, or with kagent apply -f agent.yaml + The kagent-default, codex-default and claude-default Harnesses are ready + to use in the %s namespace. +`, namespace) + return err +} + +// installModelProviders lists the providers install.defaultModelProvider +// accepts, read from its protobuf validation rule so the two cannot diverge. +func installModelProviders() []string { + field := (&configv1.InstallConfig{}).ProtoReflect().Descriptor().Fields().ByName("default_model_provider") + rules, _ := proto.GetExtension(field.Options(), validate.E_Field).(*validate.FieldRules) + return rules.GetString().GetIn() +} + +// installModelProvider selects the --provider flag over the configured default. +func installModelProvider(configured, flag string) (v1alpha3.ModelProvider, error) { + if flag == "" { + return v1alpha3.ModelProvider(configured), nil + } + if allowed := installModelProviders(); !slices.Contains(allowed, flag) { + return "", fmt.Errorf("invalid --provider %q: must be one of %s", flag, strings.Join(allowed, ", ")) + } + return v1alpha3.ModelProvider(flag), nil } func runUninstall(cmd *cobra.Command, namespace, postgresImage string, deleteWorkerPools bool) error { @@ -218,7 +257,9 @@ func NewInstallCmd() *cobra.Command { cmd := &cobra.Command{ Use: "install", Short: "Install kagent", - Long: `Install kagent. With --dry-run, write Helm commands and merged values to a directory without changing the cluster. Complete the documented prerequisites before running the exported commands.`, + Long: `Install kagent. --provider selects the default model provider, overriding install.defaultModelProvider in the CLI config; its API key is read from the provider's environment variable. + +With --dry-run, write Helm commands and merged values to a directory without changing the cluster. Complete the documented prerequisites before running the exported commands.`, RunE: func(cmd *cobra.Command, _ []string) error { if cmd.Flags().Changed("output-dir") && !cfg.DryRun { return fmt.Errorf("--output-dir requires --dry-run") @@ -234,6 +275,10 @@ func NewInstallCmd() *cobra.Command { cmd.Flags().BoolVar(&cfg.SkipDatabaseSetup, "skip-database-setup", false, "Skip bundled PostgreSQL deployment and initialization") cmd.Flags().BoolVar(&cfg.DryRun, "dry-run", false, "Export Helm commands and values without changing the cluster") cmd.Flags().StringVar(&cfg.OutputDir, "output-dir", "kagent-install", "Directory for --dry-run output (must not exist)") + cmd.Flags().StringVar(&cfg.Provider, "provider", "", "Default model provider, overriding install.defaultModelProvider ("+strings.Join(installModelProviders(), ", ")+")") + _ = cmd.RegisterFlagCompletionFunc("provider", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return installModelProviders(), cobra.ShellCompDirectiveNoFileComp + }) _ = cmd.RegisterFlagCompletionFunc("profile", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { return profiles.Profiles, cobra.ShellCompDirectiveNoFileComp }) diff --git a/go/core/cli/internal/commands/install_test.go b/go/core/cli/internal/commands/install_test.go index 70613ccb88..365951a7a2 100644 --- a/go/core/cli/internal/commands/install_test.go +++ b/go/core/cli/internal/commands/install_test.go @@ -70,6 +70,17 @@ func TestInstallProfileCleansTemporaryValuesOnFailure(t *testing.T) { require.Empty(t, entries, "the temporary profile must not outlive the install attempt") } +func TestInstallNextSteps(t *testing.T) { + t.Run("Given an install into a custom namespace, when next steps are printed, assert that they name the dashboard, agent creation and that namespace's Harnesses", func(t *testing.T) { + var out bytes.Buffer + require.NoError(t, printInstallNextSteps(&out, "team-ai")) + require.Contains(t, out.String(), "kagent dashboard") + require.Contains(t, out.String(), "kagent apply -f agent.yaml") + require.Contains(t, out.String(), "kagent-default, codex-default and claude-default Harnesses") + require.Contains(t, out.String(), "in the team-ai namespace") + }) +} + func TestConfirmWorkerPoolDeletionWithoutTerminal(t *testing.T) { pools := &installer.SelfManagedWorkerPoolsError{Pools: []types.NamespacedName{{Namespace: "team", Name: "http"}}} cmd := NewUninstallCmd() diff --git a/go/core/cli/internal/commands/version.go b/go/core/cli/internal/commands/version.go index d3e08b36c7..e677a7774b 100644 --- a/go/core/cli/internal/commands/version.go +++ b/go/core/cli/internal/commands/version.go @@ -3,7 +3,7 @@ package commands import ( "context" "encoding/json" - "os" + "io" "time" "github.com/spf13/cobra" @@ -13,12 +13,16 @@ import ( "github.com/kagent-dev/kagent/go/core/internal/version" ) -func runVersion(clientSet *client.APIClientSet) { - versionInfo := map[string]string{ +func clientVersionInfo() map[string]string { + return map[string]string{ "kagent_version": version.Version, "git_commit": version.GitCommit, "build_date": version.BuildDate, } +} + +func runVersion(out io.Writer, clientSet *client.APIClientSet) error { + versionInfo := clientVersionInfo() ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() serverVersion, err := clientSet.Version.GetVersion(ctx) @@ -27,17 +31,20 @@ func runVersion(clientSet *client.APIClientSet) { } else { versionInfo["backend_version"] = serverVersion.GetKagentVersion() } - - json.NewEncoder(os.Stdout).Encode(versionInfo) //nolint:errcheck + return json.NewEncoder(out).Encode(versionInfo) } // NewVersionCmd constructs the kagent version command. func NewVersionCmd() *cobra.Command { - return &cobra.Command{ + var clientOnly bool + cmd := &cobra.Command{ Use: "version", Short: "Print the kagent version", - Long: `Print the kagent version`, + Long: `Print the kagent version. With --client, print only the CLI version without contacting the server.`, RunE: func(cmd *cobra.Command, _ []string) error { + if clientOnly { + return json.NewEncoder(cmd.OutOrStdout()).Encode(clientVersionInfo()) + } options, err := connection.OptionsFromCommand(cmd) if err != nil { return err @@ -51,12 +58,12 @@ func NewVersionCmd() *cobra.Command { return err } defer clientSet.Close() //nolint:errcheck - runVersion(clientSet) - return nil + return runVersion(cmd.OutOrStdout(), clientSet) } defer session.Close() //nolint:errcheck - runVersion(session.API) - return nil + return runVersion(cmd.OutOrStdout(), session.API) }, } + cmd.Flags().BoolVar(&clientOnly, "client", false, "Print only the CLI version without contacting the server") + return cmd } diff --git a/go/core/cli/internal/commands/version_test.go b/go/core/cli/internal/commands/version_test.go new file mode 100644 index 0000000000..37a3ed81f9 --- /dev/null +++ b/go/core/cli/internal/commands/version_test.go @@ -0,0 +1,51 @@ +package commands + +import ( + "bytes" + "encoding/json" + "net" + "sync/atomic" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + "github.com/kagent-dev/kagent/go/core/cli/internal/connection" + "github.com/kagent-dev/kagent/go/core/internal/version" +) + +func TestVersionClientOnly(t *testing.T) { + t.Run("Given a reachable API address, when version runs with --client, assert that it prints the CLI version without contacting the server", func(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = listener.Close() }) + var connections atomic.Int32 + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + connections.Add(1) + _ = conn.Close() + } + }() + previous := version.Version + version.Version = "v9.8.7" + t.Cleanup(func() { version.Version = previous }) + root := &cobra.Command{Use: "kagent"} + connection.RegisterFlags(root.PersistentFlags()) + root.AddCommand(NewVersionCmd()) + var out bytes.Buffer + root.SetOut(&out) + root.SetArgs([]string{"--api-url", "http://" + listener.Addr().String(), "version", "--client"}) + + require.NoError(t, root.ExecuteContext(t.Context())) + + var printed map[string]string + require.NoError(t, json.Unmarshal(out.Bytes(), &printed)) + require.Equal(t, "v9.8.7", printed["kagent_version"]) + require.NotContains(t, printed, "backend_version") + require.Zero(t, connections.Load(), "--client must not connect to the API server") + }) +} diff --git a/go/core/internal/controller/golden_retry.go b/go/core/internal/controller/golden_retry.go index 6474bc3939..e1eee7fbd0 100644 --- a/go/core/internal/controller/golden_retry.go +++ b/go/core/internal/controller/golden_retry.go @@ -5,6 +5,7 @@ import ( "fmt" "hash/fnv" "math" + "strconv" "strings" "time" "unicode" @@ -97,28 +98,61 @@ func (g goldenRetries) retry(ctx context.Context, templates actorTemplateClient, failed *ateapipb.ActorTemplate, retry database.GoldenRetry, now time.Time) (goldenProgress, error) { ref := failed.GetMetadata() failure := failed.GetStatus().GetGoldenSnapshotStatus().GetErrorMessage() - // Substrate's message can quote configuration, so status names only its reason. - reason := goldenFailureReason(failure) + // Substrate's message can quote configuration, so status carries only the + // allowlisted summary. The full text goes to the log once per failed + // template; every retry replaces the template, so every attempt logs once. + summary := goldenFailureSummary(failure, actorTemplateImages(failed)) if retry.New { - logging.FromContext(ctx).WarnContext(ctx, "golden build failed", "revision", revision, + logging.FromContext(ctx).WarnContext(ctx, "golden build failed", "revision", revision, "retry", retry.Retries, "atespace", ref.GetAtespace(), "actor_template", ref.GetName(), "uid", ref.GetUid(), "error", failure) } retryAt := g.retryAt(retry, ref.GetUid()) progress := goldenProgress{phase: goldenRetrying, retries: retry.Retries} if retryAt.After(now) { - progress.message = fmt.Sprintf("golden build failed (%s), retry %d at %s", reason, retry.Retries, retryAt.UTC().Format(time.RFC3339)) + progress.message = fmt.Sprintf("%s, retry %d at %s", summary, retry.Retries, retryAt.UTC().Format(time.RFC3339)) progress.nextCheck = retryAt return progress, nil } if err := templates.DeleteActorTemplate(ctx, ref.GetAtespace(), ref.GetName(), ref.GetUid()); err != nil { - progress.message = fmt.Sprintf("golden build failed (%s), retry %d waits to delete the failed template (%s)", reason, retry.Retries, status.Code(err)) + progress.message = fmt.Sprintf("%s, retry %d waits to delete the failed template (%s)", summary, retry.Retries, status.Code(err)) return progress, fmt.Errorf("delete failed ActorTemplate %s/%s: %w", ref.GetAtespace(), ref.GetName(), err) } - progress.message = fmt.Sprintf("golden build failed (%s), retry %d started", reason, retry.Retries) + progress.message = fmt.Sprintf("%s, retry %d started", summary, retry.Retries) progress.nextCheck = now return progress, nil } +// goldenFailureSummary describes a Substrate golden failure without quoting it. +// Substrate's ": " text can carry configuration such as +// environment values, so the summary names the reason and adds detail only +// from an allowlist: atelet failing to obtain one of the template's own images. +// The image reference is taken from images, never from the message, so text +// that merely mimics the pattern cannot put anything new into status. +func goldenFailureSummary(message string, images []string) string { + summary := fmt.Sprintf("golden build failed (%s)", goldenFailureReason(message)) + for _, image := range images { + // atelet wraps every failure to resolve or pull a container or volume + // image as `in imageCache.EnsureImage("")`. + if image != "" && strings.Contains(message, "in imageCache.EnsureImage("+strconv.Quote(image)+")") { + return summary + ": could not pull image " + image + } + } + return summary +} + +// actorTemplateImages lists the image references the template asks atelet to +// pull: each container's image and each image volume's reference. +func actorTemplateImages(template *ateapipb.ActorTemplate) []string { + var images []string + for _, container := range template.GetContainers() { + images = append(images, container.GetImage()) + } + for _, volume := range template.GetVolumes() { + images = append(images, volume.GetImage().GetReference()) + } + return images +} + // goldenFailureReason returns the reason that prefixes Substrate's // ": " format, or "unknown" when the message has none. func goldenFailureReason(message string) string { diff --git a/go/core/internal/controller/golden_retry_test.go b/go/core/internal/controller/golden_retry_test.go index 7681c72a81..35417f2f8f 100644 --- a/go/core/internal/controller/golden_retry_test.go +++ b/go/core/internal/controller/golden_retry_test.go @@ -379,3 +379,108 @@ func TestGoldenRetryAt(t *testing.T) { } require.Greater(t, len(spread), 10, "different templates must retry at different times") } + +func TestGoldenFailureSummary(t *testing.T) { + const ( + typoImage = "ghcr.io/kagent-dev/kagnet/kagent-adk:0.9.0" + digestImage = "registry.example.com/agents/support@sha256:1111111111111111111111111111111111111111111111111111111111111111" + volumeImage = "registry.example.com/skills/support:v2" + ) + for _, scenario := range []struct { + name, message string + images []string + want string + }{ + { + name: "Given a container image whose tag does not resolve, when Substrate reports the crash, assert that the summary names the image", + message: goldenCrash(fmt.Sprintf(`while creating "kagent" OCI bundle: in imageCache.EnsureImage(%q): while resolving tag %q to a digest: `+ + `HEAD https://ghcr.io/v2/kagent-dev/kagnet/kagent-adk/manifests/0.9.0: unexpected status code 404 Not Found (HEAD responses have no body, use GET for details)`, typoImage, typoImage)), + images: []string{typoImage}, + want: "golden build failed (GoldenActorCrashed): could not pull image " + typoImage, + }, + { + name: "Given a pinned image the registry refuses, when Substrate reports the crash, assert that the summary names the image and not the registry's reply", + message: goldenCrash(fmt.Sprintf(`while creating "kagent" OCI bundle: in imageCache.EnsureImage(%q): GET https://registry.example.com/v2/agents/support/manifests/sha256:1111: `+ + `UNAUTHORIZED: authentication required; [map[Action:pull Class: Name:agents/support Type:repository private-scope]]`, digestImage)), + images: []string{digestImage}, + want: "golden build failed (GoldenActorCrashed): could not pull image " + digestImage, + }, + { + name: "Given an image volume that cannot be pulled, when Substrate reports the crash, assert that the summary names the volume's image", + message: goldenCrash(fmt.Sprintf(`while creating "kagent" OCI bundle: in imageCache.EnsureImage(%q) for volume "skills": while resolving tag %q to a digest: private-registry-reply`, + volumeImage, volumeImage)), + images: []string{typoImage, volumeImage}, + want: "golden build failed (GoldenActorCrashed): could not pull image " + volumeImage, + }, + { + name: "Given a crash that quotes the container environment, when Substrate reports it, assert that the summary names only the reason", + message: goldenCrash(`while resolving process args for container "kagent": exec: "start" not found in $PATH; env OPENAI_API_KEY=private-sk-123 KAGENT_CONFIG={"token":"private-token"}`), + images: []string{typoImage}, + want: "golden build failed (GoldenActorCrashed)", + }, + { + name: "Given a crash that mentions the template's image outside a pull failure, when Substrate reports it, assert that the summary names only the reason", + message: goldenCrash(fmt.Sprintf(`container "kagent" from %s exited with code 1: private-stderr`, typoImage)), + images: []string{typoImage}, + want: "golden build failed (GoldenActorCrashed)", + }, + { + name: "Given configuration that mimics a pull failure for a reference the template does not name, when Substrate quotes it, assert that the summary names only the reason", + message: goldenCrash(`while creating "kagent" OCI bundle: in imageCache.EnsureImage("private-api-key-value"): while parsing reference: could not parse reference`), + images: []string{typoImage}, + want: "golden build failed (GoldenActorCrashed)", + }, + { + name: "Given an invalid golden actor that quotes its spec, when Substrate rejects it, assert that the summary names only the reason", + message: `GoldenActorInvalid: rpc error: code = InvalidArgument desc = containers[0].env[2].value "private-config-value" exceeds 4096 bytes`, + images: []string{typoImage}, + want: "golden build failed (GoldenActorInvalid)", + }, + { + name: "Given a message without a reason prefix, when it is summarised, assert that the reason is unknown", + message: "dial private-host: connection refused", + images: []string{typoImage}, + want: "golden build failed (unknown)", + }, + } { + t.Run(strings.ReplaceAll(scenario.name, "/", "-"), func(t *testing.T) { + summary := goldenFailureSummary(scenario.message, scenario.images) + + require.Equal(t, scenario.want, summary) + require.NotContains(t, summary, "private-", "the summary must not copy Substrate's text") + }) + } +} + +// goldenCrash formats a golden failure the way Substrate v1.0.0 reports an +// atelet error that crashed the golden actor. +func goldenCrash(detail string) string { + return "GoldenActorCrashed: golden actor crashed before its snapshot was taken: resume failed: atelet Run: " + detail +} + +func TestPreparationExplainsGoldenImagePullFailure(t *testing.T) { + t.Run("Given an Agent whose Harness image cannot be pulled, when the reconciler observes the failed golden build, assert that status names the image without Substrate's text", func(t *testing.T) { + collections, _ := newPreparationTestCollections(t, "microvm") + initial := collections.Reconciliations.List()[0] + image := initial.Target.ActorTemplate.GetContainers()[0].GetImage() + failure := goldenCrash(fmt.Sprintf(`while creating "kagent" OCI bundle: in imageCache.EnsureImage(%q): while resolving tag %q to a digest: private-registry-reply`, image, image)) + templates := &fakeActorTemplates{template: withGoldenStatus(initial.Target.ActorTemplate, &ateapipb.GoldenSnapshotStatus{ErrorMessage: failure})} + templates.template.Metadata.Uid = "uid-1" + r := &Reconciler{collections: collections, templates: templates, store: &fakeRuntimeRevisionStore{}, golden: defaultGoldenRetries} + + require.NoError(t, r.reconcileAgent(t.Context(), initial.ResourceName())) + + var ready *metav1.Condition + waitFor(t, func() bool { + statuses := collections.AgentStatuses.List() + if len(statuses) != 1 { + return false + } + ready = apimeta.FindStatusCondition(statuses[0].Status.Conditions, kagentv1alpha3.AgentConditionReady) + return ready != nil && ready.Reason == "ActorTemplateRetrying" + }) + require.Equal(t, metav1.ConditionFalse, ready.Status) + require.True(t, strings.HasPrefix(ready.Message, "golden build failed (GoldenActorCrashed): could not pull image "+image+", retry 1 at "), ready.Message) + require.NotContains(t, ready.Message, "private-") + }) +} diff --git a/go/core/internal/grpcserver/agenttemplate_harness_test.go b/go/core/internal/grpcserver/agenttemplate_harness_test.go index 8caf184dda..2170e6e505 100644 --- a/go/core/internal/grpcserver/agenttemplate_harness_test.go +++ b/go/core/internal/grpcserver/agenttemplate_harness_test.go @@ -283,6 +283,73 @@ func TestHarnessServiceGeneratedClient(t *testing.T) { assertCode(t, err, codes.NotFound) } +func TestHarnessUpdate(t *testing.T) { + ctx := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("x-user-id", "harness-user")) + ref := &apiv1.ResourceReference{Namespace: "team", Name: "codex"} + + t.Run("Given a pinned Harness, when a caller updates it to a new pool without an image, assert that a later list reports the new spec and an empty image", func(t *testing.T) { + existing := testHarness("team", "codex", "pool-a") + existing.Labels = map[string]string{"tier": "old"} + client := apiv1.NewHarnessServiceClient(newTemplateAndHarnessConnection(t, existing)) + incoming := testHarness("team", "codex", "pool-b") + incoming.Spec.Workload = v1alpha3.HarnessWorkload{} + incoming.Labels = map[string]string{"tier": "new"} + + updated, err := client.UpdateHarness(ctx, &apiv1.UpdateHarnessRequest{Ref: ref, Resource: structured(t, incoming, harnessKind)}) + if err != nil { + t.Fatalf("UpdateHarness() error = %v", err) + } + if got := updated.GetHarness(); got.GetRuntime() != harnessRuntimeCodex || got.GetWorkloadImage() != "" { + t.Fatalf("UpdateHarness() = %+v, want codex runtime with no pinned image", got) + } + + listed, err := client.ListHarnesses(ctx, &apiv1.ListHarnessesRequest{Namespace: "team"}) + if err != nil { + t.Fatalf("ListHarnesses() error = %v", err) + } + if len(listed.GetHarnesses()) != 1 { + t.Fatalf("ListHarnesses() count = %d, want 1", len(listed.GetHarnesses())) + } + stored := &v1alpha3.Harness{} + if err := structuredobject.ToGo(listed.GetHarnesses()[0].GetResource(), harnessKind, stored, DefaultMaxMessageSize); err != nil { + t.Fatalf("decode listed Harness: %v", err) + } + if stored.Spec.Substrate.WorkerPoolRef.Name != "pool-b" || stored.Spec.Workload.Image != nil || stored.Labels["tier"] != "new" { + t.Fatalf("listed Harness = %+v, want pool-b, no image and the new label", stored) + } + }) + + t.Run("Given no Harness, when a caller updates one, assert that the update is not found", func(t *testing.T) { + client := apiv1.NewHarnessServiceClient(newTemplateAndHarnessConnection(t)) + _, err := client.UpdateHarness(ctx, &apiv1.UpdateHarnessRequest{Ref: ref, Resource: structured(t, testHarness("team", "codex", "pool-b"), harnessKind)}) + assertCode(t, err, codes.NotFound) + }) + + t.Run("Given a Harness, when a caller updates it with a payload naming another object, assert that the update is rejected and the stored spec is unchanged", func(t *testing.T) { + client := apiv1.NewHarnessServiceClient(newTemplateAndHarnessConnection(t, testHarness("team", "codex", "pool-a"))) + _, err := client.UpdateHarness(ctx, &apiv1.UpdateHarnessRequest{Ref: ref, Resource: structured(t, testHarness("team", "other", "pool-b"), harnessKind)}) + assertCode(t, err, codes.InvalidArgument) + + listed, err := client.ListHarnesses(ctx, &apiv1.ListHarnessesRequest{Namespace: "team"}) + if err != nil { + t.Fatalf("ListHarnesses() error = %v", err) + } + stored := &v1alpha3.Harness{} + if err := structuredobject.ToGo(listed.GetHarnesses()[0].GetResource(), harnessKind, stored, DefaultMaxMessageSize); err != nil { + t.Fatalf("decode listed Harness: %v", err) + } + if stored.Spec.Substrate.WorkerPoolRef.Name != "pool-a" { + t.Fatalf("stored pool = %q, want pool-a", stored.Spec.Substrate.WorkerPoolRef.Name) + } + }) + + t.Run("Given a Harness, when a caller updates it without a resource, assert that validation rejects the request", func(t *testing.T) { + client := apiv1.NewHarnessServiceClient(newTemplateAndHarnessConnection(t, testHarness("team", "codex", "pool-a"))) + _, err := client.UpdateHarness(ctx, &apiv1.UpdateHarnessRequest{Ref: ref}) + assertCode(t, err, codes.InvalidArgument) + }) +} + func TestHarnessRuntime(t *testing.T) { for want, spec := range map[string]v1alpha3.HarnessSpec{ harnessRuntimeKagent: {Kagent: &v1alpha3.KagentHarness{}}, diff --git a/go/core/internal/grpcserver/harness.go b/go/core/internal/grpcserver/harness.go index 54e5c73380..d657dcc654 100644 --- a/go/core/internal/grpcserver/harness.go +++ b/go/core/internal/grpcserver/harness.go @@ -2,6 +2,7 @@ package grpcserver import ( "context" + "maps" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" @@ -66,6 +67,34 @@ func (s *harnessServer) CreateHarness(ctx context.Context, request *apiv1.Create return &apiv1.CreateHarnessResponse{Harness: encoded}, nil } +// UpdateHarness replaces the spec and labels of the stored object, keeping its +// resourceVersion so a concurrent edit is refused rather than overwritten. +func (s *harnessServer) UpdateHarness(ctx context.Context, request *apiv1.UpdateHarnessRequest) (*apiv1.UpdateHarnessResponse, error) { + ref, err := requiredHarnessRef(request.GetRef()) + if err != nil { + return nil, err + } + incoming := &v1alpha3.Harness{} + if err := s.decodeResource(request.GetRef(), request.GetResource(), incoming); err != nil { + return nil, err + } + existing, err := s.service.Get(ctx, ref) + if err != nil { + return nil, err + } + existing.Spec = *incoming.Spec.DeepCopy() + existing.Labels = maps.Clone(incoming.Labels) + result, err := s.service.Update(ctx, existing) + if err != nil { + return nil, err + } + encoded, err := s.harness(result) + if err != nil { + return nil, err + } + return &apiv1.UpdateHarnessResponse{Harness: encoded}, nil +} + func (s *harnessServer) DeleteHarness(ctx context.Context, request *apiv1.DeleteHarnessRequest) (*apiv1.DeleteHarnessResponse, error) { ref, err := requiredHarnessRef(request.GetRef()) if err != nil { diff --git a/go/core/internal/grpcserver/policy.go b/go/core/internal/grpcserver/policy.go index e6a249d6f5..3141f01d24 100644 --- a/go/core/internal/grpcserver/policy.go +++ b/go/core/internal/grpcserver/policy.go @@ -16,41 +16,45 @@ type MethodPolicies map[string]auth.AccessMode func DefaultMethodPolicies() MethodPolicies { policies := MethodPolicies{ - runtimev1.BootstrapService_GetBootstrapConfig_FullMethodName: auth.AccessRead, - apiv1.AgentIdentityService_ResolveAgentIdentity_FullMethodName: auth.AccessRead, - apiv1.SystemService_GetVersion_FullMethodName: auth.AccessPublic, - apiv1.SystemService_GetCurrentUser_FullMethodName: auth.AccessRead, - apiv1.SystemService_ListNamespaces_FullMethodName: auth.AccessRead, - apiv1.SystemService_GetSubstrateSummary_FullMethodName: auth.AccessRead, - apiv1.SystemService_ListSubstrateActors_FullMethodName: auth.AccessRead, - apiv1.SystemService_ListSubstrateWorkers_FullMethodName: auth.AccessRead, - apiv1.ModelService_ListModelConfigs_FullMethodName: auth.AccessRead, - apiv1.ModelService_GetModelConfig_FullMethodName: auth.AccessRead, - apiv1.ModelService_CreateModelConfig_FullMethodName: auth.AccessCreate, - apiv1.ModelService_UpdateModelConfig_FullMethodName: auth.AccessUpdate, - apiv1.ModelService_DeleteModelConfig_FullMethodName: auth.AccessDelete, - apiv1.ModelService_ListSupportedModelProviders_FullMethodName: auth.AccessRead, - apiv1.ModelService_ListConfiguredProviders_FullMethodName: auth.AccessRead, - apiv1.ModelService_ListProviderModels_FullMethodName: auth.AccessRead, - apiv1.ModelService_ListSupportedModels_FullMethodName: auth.AccessRead, - apiv1.ToolService_ListTools_FullMethodName: auth.AccessRead, - apiv1.ToolService_ListToolServers_FullMethodName: auth.AccessRead, - apiv1.ToolService_CreateToolServer_FullMethodName: auth.AccessCreate, - apiv1.ToolService_DeleteToolServer_FullMethodName: auth.AccessDelete, - apiv1.ToolService_ListToolServerTypes_FullMethodName: auth.AccessRead, - apiv1.ToolService_ListMCPAppTools_FullMethodName: auth.AccessRead, - apiv1.ToolService_CallMCPAppTool_FullMethodName: auth.AccessCreate, - apiv1.ToolService_ReadMCPAppResource_FullMethodName: auth.AccessRead, - apiv1.PromptTemplateService_ListPromptTemplates_FullMethodName: auth.AccessRead, - apiv1.PromptTemplateService_GetPromptTemplate_FullMethodName: auth.AccessRead, - apiv1.PromptTemplateService_CreatePromptTemplate_FullMethodName: auth.AccessCreate, - apiv1.PromptTemplateService_UpdatePromptTemplate_FullMethodName: auth.AccessUpdate, - apiv1.PromptTemplateService_DeletePromptTemplate_FullMethodName: auth.AccessDelete, - grpc_health_v1.Health_Check_FullMethodName: auth.AccessPublic, - grpc_health_v1.Health_List_FullMethodName: auth.AccessPublic, - grpc_health_v1.Health_Watch_FullMethodName: auth.AccessPublic, - "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo": auth.AccessPublic, - "/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo": auth.AccessPublic, + runtimev1.BootstrapService_GetBootstrapConfig_FullMethodName: auth.AccessRead, + apiv1.AgentIdentityService_ResolveAgentIdentity_FullMethodName: auth.AccessRead, + apiv1.SystemService_GetVersion_FullMethodName: auth.AccessPublic, + apiv1.SystemService_GetCurrentUser_FullMethodName: auth.AccessRead, + apiv1.SystemService_ListNamespaces_FullMethodName: auth.AccessRead, + apiv1.SystemService_GetSubstrateSummary_FullMethodName: auth.AccessRead, + apiv1.SystemService_ListSubstrateActors_FullMethodName: auth.AccessRead, + apiv1.SystemService_ListSubstrateWorkers_FullMethodName: auth.AccessRead, + apiv1.SystemService_ListWorkerPools_FullMethodName: auth.AccessRead, + apiv1.ModelService_ListModelConfigs_FullMethodName: auth.AccessRead, + apiv1.ModelService_GetModelConfig_FullMethodName: auth.AccessRead, + apiv1.ModelService_CreateModelConfig_FullMethodName: auth.AccessCreate, + apiv1.ModelService_UpdateModelConfig_FullMethodName: auth.AccessUpdate, + apiv1.ModelService_DeleteModelConfig_FullMethodName: auth.AccessDelete, + apiv1.ModelService_ListSupportedModelProviders_FullMethodName: auth.AccessRead, + apiv1.ModelService_ListConfiguredProviders_FullMethodName: auth.AccessRead, + apiv1.ModelService_ListProviderModels_FullMethodName: auth.AccessRead, + apiv1.ModelService_ListSupportedModels_FullMethodName: auth.AccessRead, + apiv1.ToolService_ListTools_FullMethodName: auth.AccessRead, + apiv1.ToolService_ListToolServers_FullMethodName: auth.AccessRead, + apiv1.ToolService_CreateToolServer_FullMethodName: auth.AccessCreate, + apiv1.ToolService_DeleteToolServer_FullMethodName: auth.AccessDelete, + apiv1.ToolService_ListToolServerTypes_FullMethodName: auth.AccessRead, + apiv1.ToolService_ListMCPAppTools_FullMethodName: auth.AccessRead, + apiv1.ToolService_CallMCPAppTool_FullMethodName: auth.AccessCreate, + apiv1.ToolService_ReadMCPAppResource_FullMethodName: auth.AccessRead, + apiv1.PromptTemplateService_ListPromptTemplates_FullMethodName: auth.AccessRead, + apiv1.PromptTemplateService_GetPromptTemplate_FullMethodName: auth.AccessRead, + apiv1.PromptTemplateService_CreatePromptTemplate_FullMethodName: auth.AccessCreate, + apiv1.PromptTemplateService_UpdatePromptTemplate_FullMethodName: auth.AccessUpdate, + apiv1.PromptTemplateService_DeletePromptTemplate_FullMethodName: auth.AccessDelete, + grpc_health_v1.Health_Check_FullMethodName: auth.AccessPublic, + grpc_health_v1.Health_List_FullMethodName: auth.AccessPublic, + grpc_health_v1.Health_Watch_FullMethodName: auth.AccessPublic, + // Reflection describes every registered service, including consumer + // extensions, so it follows the configured authentication. Insecure mode + // still answers anonymous callers. + "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo": auth.AccessRead, + "/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo": auth.AccessRead, apiv1.AgentService_ListAgents_FullMethodName: auth.AccessRead, apiv1.AgentService_GetAgent_FullMethodName: auth.AccessRead, apiv1.AgentService_CreateAgent_FullMethodName: auth.AccessCreate, @@ -63,6 +67,7 @@ func DefaultMethodPolicies() MethodPolicies { apiv1.AgentTemplateService_DeleteAgentTemplate_FullMethodName: auth.AccessDelete, apiv1.HarnessService_ListHarnesses_FullMethodName: auth.AccessRead, apiv1.HarnessService_CreateHarness_FullMethodName: auth.AccessCreate, + apiv1.HarnessService_UpdateHarness_FullMethodName: auth.AccessUpdate, apiv1.HarnessService_DeleteHarness_FullMethodName: auth.AccessDelete, apiv1.SandboxTemplateService_ListSandboxTemplates_FullMethodName: auth.AccessRead, diff --git a/go/core/internal/grpcserver/policy_test.go b/go/core/internal/grpcserver/policy_test.go index 655c8e926c..b73e03c59c 100644 --- a/go/core/internal/grpcserver/policy_test.go +++ b/go/core/internal/grpcserver/policy_test.go @@ -1,6 +1,7 @@ package grpcserver import ( + "strings" "testing" "time" @@ -186,3 +187,48 @@ func TestActorAndUserPoliciesOnSharedEndpoint(t *testing.T) { }) } } + +func TestReflectionFollowsConfiguredAuthentication(t *testing.T) { + issuer := jwttest.New(t) + user := "Bearer " + issuer.Token(t, jwt.MapClaims{"iss": "https://users.example", "sub": "alice"}) + methods := []string{ + "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo", + "/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo", + } + scenarios := []struct { + name string + provider pkgauth.AuthProvider + token string + want codes.Code + }{ + { + name: "Given insecure authentication, when an anonymous caller lists services through reflection, assert that the call is admitted", + provider: &authimpl.InsecureAuthenticator{}, + want: codes.OK, + }, + { + name: "Given trusted proxy authentication, when an anonymous caller lists services through reflection, assert that the call is unauthenticated", + provider: authimpl.NewProxyAuthenticator("sub"), + want: codes.Unauthenticated, + }, + { + name: "Given trusted proxy authentication, when an authenticated user lists services through reflection, assert that the call is admitted", + provider: authimpl.NewProxyAuthenticator("sub"), + token: user, + want: codes.OK, + }, + } + for _, scenario := range scenarios { + for _, method := range methods { + t.Run(scenario.name+" ("+strings.ReplaceAll(strings.TrimPrefix(method, "/"), "/", " ")+")", func(t *testing.T) { + md := metadata.MD{} + if scenario.token != "" { + md.Set("authorization", scenario.token) + } + ctx := metadata.NewIncomingContext(t.Context(), md) + _, err := authenticate(ctx, method, scenario.provider, nil, DefaultMethodPolicies(), nil, nil) + require.Equal(t, scenario.want, status.Code(err), "%v", err) + }) + } + } +} diff --git a/go/core/internal/grpcserver/resource_authorization_test.go b/go/core/internal/grpcserver/resource_authorization_test.go index b8e3ddf987..eefa91a97b 100644 --- a/go/core/internal/grpcserver/resource_authorization_test.go +++ b/go/core/internal/grpcserver/resource_authorization_test.go @@ -22,6 +22,7 @@ import ( apiauthorization "github.com/kagent-dev/kagent/go/api/authorization" apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/service/kubecrud" modelservice "github.com/kagent-dev/kagent/go/core/internal/service/model" promptservice "github.com/kagent-dev/kagent/go/core/internal/service/prompttemplate" systemservice "github.com/kagent-dev/kagent/go/core/internal/service/system" @@ -70,6 +71,7 @@ func TestKubernetesResourceScopesThroughGeneratedClients(t *testing.T) { config.PromptTemplateService = promptservice.NewService(kube, authorizer) config.ModelService = modelservice.NewService(kube, authorizer, "team", modelservice.WithProviderModelRefresher(refresher)) config.SystemService = systemservice.NewService(kube, []string{"team"}, authorizer, emptySystemATEClient{}) + config.WorkerPoolService = kubecrud.NewService(kube, authorizer, &atev1.WorkerPool{}, &atev1.WorkerPoolList{}, "WorkerPool") }) ctx := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("x-user-id", "reader")) prompts := apiv1.NewPromptTemplateServiceClient(connection) @@ -101,6 +103,14 @@ func TestKubernetesResourceScopesThroughGeneratedClients(t *testing.T) { require.Equal(t, "allowed", summary.GetWorkerPools()[0].GetRef().GetName()) require.Zero(t, summary.GetActorCount()) require.Empty(t, summary.GetActorTemplates()) + + pools, err := apiv1.NewSystemServiceClient(connection).ListWorkerPools(ctx, &apiv1.ListWorkerPoolsRequest{Namespace: "team"}) + require.NoError(t, err) + require.Len(t, pools.GetWorkerPools(), 1) + require.Equal(t, "allowed", pools.GetWorkerPools()[0].GetRef().GetName()) + require.Equal(t, "WorkerPool", pools.GetWorkerPools()[0].GetResource().GetKind()) + _, err = apiv1.NewSystemServiceClient(connection).ListWorkerPools(ctx, &apiv1.ListWorkerPoolsRequest{Namespace: "Team/Bad"}) + assertGRPCCode(t, err, codes.InvalidArgument) } func TestProviderModelRefreshDeniedThroughShareRPC(t *testing.T) { diff --git a/go/core/internal/grpcserver/server.go b/go/core/internal/grpcserver/server.go index c0a2fdade5..85bb3133bb 100644 --- a/go/core/internal/grpcserver/server.go +++ b/go/core/internal/grpcserver/server.go @@ -15,6 +15,7 @@ import ( "github.com/a2aproject/a2a-go/v2/a2asrv" guestpb "github.com/agent-substrate/env/proto/ateenv/v1alpha" protovalidatemiddleware "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/protovalidate" + atev1 "go.ate.dev/substrate/api/infra/k8s/ate/v1" "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc/filters" "google.golang.org/grpc" @@ -67,6 +68,7 @@ type Config struct { ToolService *toolservice.Service PromptTemplateService *prompttemplateservice.Service SystemService *systemservice.Service + WorkerPoolService *kubecrud.Service[*atev1.WorkerPool, *atev1.WorkerPoolList] MemoryService *memoryservice.Service TaskStoreService *taskstore.Service SessionService *sessionsvc.Service @@ -137,7 +139,7 @@ func New(config Config) (*Server, error) { grpcServer := grpc.NewServer(serverOptions...) healthServer := health.NewServer() grpc_health_v1.RegisterHealthServer(grpcServer, healthServer) - apiv1.RegisterSystemServiceServer(grpcServer, newSystemServer(config.SystemService, config.MaxMessageBytes)) + apiv1.RegisterSystemServiceServer(grpcServer, newSystemServer(config.SystemService, config.WorkerPoolService, config.MaxMessageBytes)) apiv1.RegisterAgentServiceServer(grpcServer, newAgentServer(config.AgentService, config.MaxMessageBytes)) runtimev1.RegisterBootstrapServiceServer(grpcServer, &bootstrapServer{service: config.BootstrapService}) apiv1.RegisterAgentIdentityServiceServer(grpcServer, &agentIdentityServer{service: config.AgentIdentityService}) diff --git a/go/core/internal/grpcserver/system.go b/go/core/internal/grpcserver/system.go index 9f928d55ff..54d6be4e88 100644 --- a/go/core/internal/grpcserver/system.go +++ b/go/core/internal/grpcserver/system.go @@ -9,6 +9,7 @@ import ( apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" "github.com/kagent-dev/kagent/go/api/structuredobject" + "github.com/kagent-dev/kagent/go/core/internal/service/kubecrud" "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" systemservice "github.com/kagent-dev/kagent/go/core/internal/service/system" ) @@ -16,11 +17,12 @@ import ( type systemServer struct { apiv1.UnimplementedSystemServiceServer service *systemservice.Service + workerPools *kubecrud.Service[*atev1.WorkerPool, *atev1.WorkerPoolList] maxMessageBytes int } -func newSystemServer(service *systemservice.Service, maxMessageBytes int) *systemServer { - return &systemServer{service: service, maxMessageBytes: maxMessageBytes} +func newSystemServer(service *systemservice.Service, workerPools *kubecrud.Service[*atev1.WorkerPool, *atev1.WorkerPoolList], maxMessageBytes int) *systemServer { + return &systemServer{service: service, workerPools: workerPools, maxMessageBytes: maxMessageBytes} } func (s *systemServer) GetVersion(context.Context, *apiv1.GetVersionRequest) (*apiv1.GetVersionResponse, error) { @@ -119,6 +121,22 @@ func (s *systemServer) ListSubstrateWorkers(ctx context.Context, request *apiv1. return response, nil } +func (s *systemServer) ListWorkerPools(ctx context.Context, request *apiv1.ListWorkerPoolsRequest) (*apiv1.ListWorkerPoolsResponse, error) { + pools, err := s.workerPools.List(ctx, request.GetNamespace()) + if err != nil { + return nil, err + } + response := &apiv1.ListWorkerPoolsResponse{WorkerPools: make([]*apiv1.SubstrateWorkerPool, 0, len(pools))} + for _, pool := range pools { + encoded, err := s.workerPool(pool) + if err != nil { + return nil, err + } + response.WorkerPools = append(response.WorkerPools, encoded) + } + return response, nil +} + func (s *systemServer) workerPool(workerPool *atev1.WorkerPool) (*apiv1.SubstrateWorkerPool, error) { resource, err := structuredobject.FromGo(workerPool, atev1.GroupVersion.String(), "WorkerPool", s.maxMessageBytes) if err != nil { diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index 2b3458dbd5..b2c1ab8cde 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -460,6 +460,7 @@ func Run(ctx context.Context, input *configv1.ControllerConfig, opts Options) er ToolService: tools, PromptTemplateService: prompts, SystemService: system, + WorkerPoolService: kubecrud.NewService(manager.GetClient(), authorizer, &atev1.WorkerPool{}, &atev1.WorkerPoolList{}, "WorkerPool"), MemoryService: memory, TaskStoreService: runtimeTasks, SessionService: sessions, diff --git a/go/core/pkg/install/plan.go b/go/core/pkg/install/plan.go index 0094afae17..9868823297 100644 --- a/go/core/pkg/install/plan.go +++ b/go/core/pkg/install/plan.go @@ -113,6 +113,13 @@ config: substrate: apiEndpoint: dns:///api.ate-system.svc:443 routerUrl: https://atenet-router.ate-system.svc:443 + # Getting-started installs answer grpcurl without local proto files. + # Reflection still requires whatever authentication the operator configures. + server: + grpcReflection: true +# Ready-to-reference Harnesses on the WorkerPool created below. +harnesses: + enabled: true ` + fmt.Sprintf("\nsubstrateWorkerPool:\n create: true\n workerImage: %s\n", strconv.Quote(cfg.WorkerImage)) if !cfg.SkipDatabaseSetup { kagent.Values = append(kagent.Values, "database.postgres.clientCertificate.enabled=true") diff --git a/go/core/pkg/install/plan_test.go b/go/core/pkg/install/plan_test.go index 152aba6c0e..35724b95ac 100644 --- a/go/core/pkg/install/plan_test.go +++ b/go/core/pkg/install/plan_test.go @@ -380,3 +380,94 @@ func TestModelCredentialValuesPreserveHelmPrecedence(t *testing.T) { }) } } + +func TestPlanEnablesGRPCReflection(t *testing.T) { + scenarios := []struct { + name string + operator string + want string + }{ + { + name: "Given chart defaults that disable gRPC reflection, when planning an install, assert that reflection is enabled", + want: "true", + }, + { + name: "Given an operator values file that disables gRPC reflection, when planning an install, assert that the operator choice wins", + operator: "config: {server: {grpcReflection: false}}\n", + want: "false", + }, + } + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + cfg := localInstallConfig(t) + chart := filepath.Join(cfg.Kagent.Repository, cfg.Kagent.Name) + require.NoError(t, os.WriteFile(filepath.Join(chart, "values.yaml"), []byte("config: {server: {grpcReflection: false}}\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(chart, "templates", "reflection.yaml"), []byte("apiVersion: v1\nkind: ConfigMap\nmetadata: {name: reflection}\ndata:\n reflection: {{ .Values.config.server.grpcReflection | quote }}\n"), 0o600)) + if scenario.operator != "" { + operator := filepath.Join(t.TempDir(), "operator.yaml") + require.NoError(t, os.WriteFile(operator, []byte(scenario.operator), 0o600)) + cfg.Kagent.ValuesFiles = []string{operator} + } + runner := CommandRunner{} + plan, err := NewClient(runner, nil).Plan(t.Context(), cfg) + require.NoError(t, err) + + steps := plan.Steps() + release := steps[len(steps)-1].(ChartInstall) + command := release.Command() + command.Args = append([]string{"template", release.Release, chartReference(release.Chart), "--show-only", "templates/reflection.yaml"}, helmValueArgs(release.Chart)...) + out, err := runner.Run(t.Context(), command) + require.NoError(t, err) + + var rendered struct{ Data map[string]string } + require.NoError(t, yaml.Unmarshal([]byte(out), &rendered)) + require.Equal(t, scenario.want, rendered.Data["reflection"]) + }) + } +} + +func TestPlanEnablesDefaultHarnesses(t *testing.T) { + scenarios := []struct { + name string + operator string + wantHarness string + }{ + { + name: "Given chart defaults that disable default Harnesses, when planning an install, assert that the WorkerPool and default Harnesses are enabled", + wantHarness: "true", + }, + { + name: "Given an operator values file that disables default Harnesses, when planning an install, assert that the operator choice wins", + operator: "harnesses: {enabled: false}\n", + wantHarness: "false", + }, + } + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + cfg := localInstallConfig(t) + chart := filepath.Join(cfg.Kagent.Repository, cfg.Kagent.Name) + require.NoError(t, os.WriteFile(filepath.Join(chart, "values.yaml"), []byte("harnesses: {enabled: false}\nsubstrateWorkerPool: {create: false}\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(chart, "templates", "harnesses.yaml"), []byte("apiVersion: v1\nkind: ConfigMap\nmetadata: {name: harnesses}\ndata:\n harnesses: {{ .Values.harnesses.enabled | quote }}\n pool: {{ .Values.substrateWorkerPool.create | quote }}\n"), 0o600)) + if scenario.operator != "" { + operator := filepath.Join(t.TempDir(), "operator.yaml") + require.NoError(t, os.WriteFile(operator, []byte(scenario.operator), 0o600)) + cfg.Kagent.ValuesFiles = []string{operator} + } + runner := CommandRunner{} + plan, err := NewClient(runner, nil).Plan(t.Context(), cfg) + require.NoError(t, err) + + steps := plan.Steps() + release := steps[len(steps)-1].(ChartInstall) + command := release.Command() + command.Args = append([]string{"template", release.Release, chartReference(release.Chart), "--show-only", "templates/harnesses.yaml"}, helmValueArgs(release.Chart)...) + out, err := runner.Run(t.Context(), command) + require.NoError(t, err) + + var rendered struct{ Data map[string]string } + require.NoError(t, yaml.Unmarshal([]byte(out), &rendered)) + require.Equal(t, scenario.wantHarness, rendered.Data["harnesses"]) + require.Equal(t, "true", rendered.Data["pool"]) + }) + } +} diff --git a/go/core/test/e2e/default_image_test.go b/go/core/test/e2e/default_image_test.go index 087ddd5676..d8f835045f 100644 --- a/go/core/test/e2e/default_image_test.go +++ b/go/core/test/e2e/default_image_test.go @@ -37,18 +37,90 @@ func TestBuiltinHarnessDefaultImages(t *testing.T) { agent.Spec.HarnessRef = corev1.LocalObjectReference{Name: harness.Name} require.NoError(t, kube.Create(t.Context(), agent)) t.Cleanup(func() { require.NoError(t, kube.Delete(context.Background(), agent)) }) - err := wait.PollUntilContextTimeout(t.Context(), time.Second, 2*time.Minute, true, func(ctx context.Context) (bool, error) { - if err := kube.Get(ctx, ctrlclient.ObjectKeyFromObject(agent), agent); err != nil { - return false, err - } - condition := meta.FindStatusCondition(agent.Status.Conditions, v1alpha3.AgentConditionReady) - return condition != nil && condition.ObservedGeneration == agent.Generation && condition.Status == metav1.ConditionTrue, nil - }) - require.NoError(t, err, "last conditions: %+v", agent.Status.Conditions) + waitForAgentRevision(t, kube, agent) require.Equal(t, source.Spec.Workload.Image, agent.Status.WorkloadImage) - require.NotEmpty(t, agent.Status.DesiredRevision) - require.Equal(t, agent.Status.DesiredRevision, agent.Status.LatestSuccessfulRevision) require.NoError(t, kube.Get(t.Context(), ctrlclient.ObjectKeyFromObject(harness), harness)) require.Nil(t, harness.Spec.Workload.Image) }) } + +// The default ModelConfig and Harnesses come from `kagent install`, which the +// suite uses to create the cluster. +const installedDefaultModelConfig = "default-model-config" + +func TestInstalledDefaultHarnesses(t *testing.T) { + interactionTarget(t) + scenarios := []struct { + name string + harness string + // model returns the ModelConfig the Agent references. + model func(*testing.T, ctrlclient.Client) string + hasKind func(v1alpha3.HarnessSpec) bool + }{ + { + name: "Given the installed kagent-default Harness, when an Agent references it with the installed default model, assert that the Agent becomes ready", + harness: "kagent-default", + model: func(*testing.T, ctrlclient.Client) string { return installedDefaultModelConfig }, + hasKind: func(spec v1alpha3.HarnessSpec) bool { return spec.Kagent != nil }, + }, + { + name: "Given the installed codex-default Harness, when an Agent references it with the installed default OpenAI model, assert that the Agent becomes ready", + harness: "codex-default", + model: func(t *testing.T, kube ctrlclient.Client) string { + model := &v1alpha3.ModelConfig{} + require.NoError(t, kube.Get(t.Context(), ctrlclient.ObjectKey{Namespace: "kagent", Name: installedDefaultModelConfig}, model)) + if model.Spec.Provider != v1alpha3.ModelProviderOpenAI { + t.Skipf("the installed default model uses %s; Codex accepts only OpenAI", model.Spec.Provider) + } + return model.Name + }, + hasKind: func(spec v1alpha3.HarnessSpec) bool { return spec.Codex != nil }, + }, + { + name: "Given the installed claude-default Harness, when an Agent references it with an Anthropic model, assert that the Agent becomes ready", + harness: "claude-default", + model: func(t *testing.T, kube ctrlclient.Client) string { + return testHarness{name: claudeE2EHarness}.createModel(t, kube, startInteractionMock(t), nil).Name + }, + hasKind: func(spec v1alpha3.HarnessSpec) bool { return spec.Claude != nil }, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + kube := interactionKubeClient(t) + harness := &v1alpha3.Harness{} + require.NoError(t, kube.Get(t.Context(), ctrlclient.ObjectKey{Namespace: "kagent", Name: scenario.harness}, harness)) + require.True(t, scenario.hasKind(harness.Spec), "unexpected runtime in %+v", harness.Spec) + require.Nil(t, harness.Spec.Workload.Image, "default Harnesses use the release's built-in image") + require.Equal(t, "kagent-default", harness.Spec.Substrate.WorkerPoolRef.Name) + require.Equal(t, "s3://ate-snapshots/kagent/", harness.Spec.Substrate.SnapshotPolicy.Location) + + agent := &v1alpha3.Agent{ + ObjectMeta: metav1.ObjectMeta{GenerateName: "default-harness-", Namespace: harness.Namespace}, + Spec: v1alpha3.AgentSpec{ + HarnessRef: corev1.LocalObjectReference{Name: harness.Name}, + Template: v1alpha3.AgentTemplateSource{Inline: &v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.ObjectReference{Name: scenario.model(t, kube)}, SystemPrompt: "Be helpful."}}, + }, + } + require.NoError(t, kube.Create(t.Context(), agent)) + t.Cleanup(func() { require.NoError(t, kube.Delete(context.Background(), agent)) }) + waitForAgentRevision(t, kube, agent) + }) + } +} + +// waitForAgentRevision waits until the controller has prepared the Agent's +// current generation, leaving the latest status in agent. +func waitForAgentRevision(t *testing.T, kube ctrlclient.Client, agent *v1alpha3.Agent) { + t.Helper() + err := wait.PollUntilContextTimeout(t.Context(), time.Second, 2*time.Minute, true, func(ctx context.Context) (bool, error) { + if err := kube.Get(ctx, ctrlclient.ObjectKeyFromObject(agent), agent); err != nil { + return false, err + } + condition := meta.FindStatusCondition(agent.Status.Conditions, v1alpha3.AgentConditionReady) + return condition != nil && condition.ObservedGeneration == agent.Generation && condition.Status == metav1.ConditionTrue, nil + }) + require.NoError(t, err, "last conditions: %+v", agent.Status.Conditions) + require.NotEmpty(t, agent.Status.DesiredRevision) + require.Equal(t, agent.Status.DesiredRevision, agent.Status.LatestSuccessfulRevision) +} diff --git a/go/core/test/e2e/manifests/lifecycle.yaml.tmpl b/go/core/test/e2e/manifests/lifecycle.yaml.tmpl index b435b925f2..39e31579f3 100644 --- a/go/core/test/e2e/manifests/lifecycle.yaml.tmpl +++ b/go/core/test/e2e/manifests/lifecycle.yaml.tmpl @@ -11,7 +11,7 @@ spec: workerPoolRef: name: kagent-default snapshotPolicy: - location: gs://ate-snapshots/kagent/ + location: s3://ate-snapshots/kagent/ --- apiVersion: api.kagent.dev/v1alpha3 kind: Harness @@ -30,7 +30,7 @@ spec: workerPoolRef: name: kagent-default snapshotPolicy: - location: gs://ate-snapshots/kagent/ + location: s3://ate-snapshots/kagent/ --- apiVersion: api.kagent.dev/v1alpha3 kind: Harness @@ -46,7 +46,7 @@ spec: workerPoolRef: name: kagent-default snapshotPolicy: - location: gs://ate-snapshots/kagent/ + location: s3://ate-snapshots/kagent/ --- apiVersion: api.kagent.dev/v1alpha3 kind: Harness @@ -62,7 +62,7 @@ spec: workerPoolRef: name: kagent-default snapshotPolicy: - location: gs://ate-snapshots/kagent/ + location: s3://ate-snapshots/kagent/ --- apiVersion: api.kagent.dev/v1alpha3 kind: Harness @@ -81,7 +81,7 @@ spec: workerPoolRef: name: kagent-default snapshotPolicy: - location: gs://ate-snapshots/kagent/ + location: s3://ate-snapshots/kagent/ --- apiVersion: api.kagent.dev/v1alpha3 kind: AgentTemplate diff --git a/go/core/test/e2e/manifests/tracing.yaml.tmpl b/go/core/test/e2e/manifests/tracing.yaml.tmpl index fc2d846072..9aa794bb13 100644 --- a/go/core/test/e2e/manifests/tracing.yaml.tmpl +++ b/go/core/test/e2e/manifests/tracing.yaml.tmpl @@ -50,7 +50,7 @@ spec: workerPoolRef: name: kagent-default snapshotPolicy: - location: gs://ate-snapshots/kagent/ + location: s3://ate-snapshots/kagent/ --- apiVersion: api.kagent.dev/v1alpha3 kind: Harness @@ -69,4 +69,4 @@ spec: workerPoolRef: name: kagent-default snapshotPolicy: - location: gs://ate-snapshots/kagent/ + location: s3://ate-snapshots/kagent/ diff --git a/go/harness/claude/README.md b/go/harness/claude/README.md index c22daf60e4..999e508b9f 100644 --- a/go/harness/claude/README.md +++ b/go/harness/claude/README.md @@ -123,7 +123,7 @@ spec: workerPoolRef: name: kagent-default snapshotPolicy: - location: gs://ate-snapshots/kagent/ + location: s3://ate-snapshots/kagent/ --- apiVersion: api.kagent.dev/v1alpha3 kind: Agent diff --git a/go/harness/codex/README.md b/go/harness/codex/README.md index a7581ce77f..5e1871454c 100644 --- a/go/harness/codex/README.md +++ b/go/harness/codex/README.md @@ -151,7 +151,7 @@ spec: workerPoolRef: name: kagent-default snapshotPolicy: - location: gs://ate-snapshots/kagent/ + location: s3://ate-snapshots/kagent/ --- apiVersion: api.kagent.dev/v1alpha3 kind: Agent diff --git a/helm/README.md b/helm/README.md index 51c82c55bd..6158bb6763 100644 --- a/helm/README.md +++ b/helm/README.md @@ -325,6 +325,40 @@ release namespace. **Note**: MicroVM requires a `microvm` SandboxConfig, runtime assets, and KVM-capable workers. kagent does not install these prerequisites. +#### Default Harnesses + +`kagent install` creates the WorkerPool and three Harnesses in the release +namespace, so an Agent there can reference one without writing its own: + +| Harness | Runtime | +| --- | --- | +| `kagent-default` | kagent | +| `codex-default` | Codex | +| `claude-default` | Claude | + +Direct Helm installations opt in with `harnesses.enabled=true`: + +```yaml +substrateWorkerPool: + create: true + workerImage: +harnesses: + enabled: true + # Substrate snapshot storage. The default uses the bucket of Substrate's bundled RustFS. + snapshotLocation: s3://ate-snapshots/kagent/ + # Skip an individual runtime: + claude: + enabled: false +``` + +Each default Harness references `substrateWorkerPool.name` and omits +`workload`, so it runs the release's built-in image for its runtime +(`config.harnessImages`). Agent `spec.harnessRef` is namespace-local; Agents in +other namespaces need their own Harness. + +The default OpenAI ModelConfig sets `apiFormat: responses`, which the Codex +runtime requires and the kagent runtime supports. + ### Using Make ```bash @@ -343,11 +377,17 @@ Use `kagent install --dry-run --output-dir ./kagent-install` to export the Helm ```bash export OPENAI_API_KEY=your-openai-api-key -# openAI is the default; other choices include anthropic, azureOpenAI, gemini, and ollama +# OpenAI is the default; --provider Anthropic (with ANTHROPIC_API_KEY) overrides +# install.defaultModelProvider for one install. # config.yaml: install: {defaultModelProvider: OpenAI} kagent --config config.yaml install ``` +To install the CLI first, run `curl -sL https://kagent.dev/install | bash`. It +installs the newest final release that publishes a binary for your platform; +pass `--prerelease` to include release candidates or `--version vX.Y.Z` to pin +one. + ## Application configuration `config` follows the [ControllerConfig schema](../proto/kagent/config/v1/config.proto). diff --git a/helm/kagent/templates/harnesses.yaml b/helm/kagent/templates/harnesses.yaml new file mode 100644 index 0000000000..7269b027b8 --- /dev/null +++ b/helm/kagent/templates/harnesses.yaml @@ -0,0 +1,24 @@ +{{- if .Values.harnesses.enabled }} +{{- if not .Values.harnesses.snapshotLocation }} +{{- fail "harnesses.snapshotLocation is required when harnesses.enabled=true" }} +{{- end }} +{{- range $adapter := list "kagent" "codex" "claude" }} +{{- if (index $.Values.harnesses $adapter).enabled }} +--- +apiVersion: api.kagent.dev/v1alpha3 +kind: Harness +metadata: + name: {{ printf "%s-default" $adapter }} + namespace: {{ include "kagent.namespace" $ }} + labels: + {{- include "kagent.labels" $ | nindent 4 }} +spec: + {{ $adapter }}: {} + substrate: + workerPoolRef: + name: {{ $.Values.substrateWorkerPool.name | toString | quote }} + snapshotPolicy: + location: {{ $.Values.harnesses.snapshotLocation | quote }} +{{- end }} +{{- end }} +{{- end }} diff --git a/helm/kagent/tests/harnesses_test.yaml b/helm/kagent/tests/harnesses_test.yaml new file mode 100644 index 0000000000..1abff4d601 --- /dev/null +++ b/helm/kagent/tests/harnesses_test.yaml @@ -0,0 +1,102 @@ +suite: test default harnesses +templates: + - harnesses.yaml +release: + namespace: kagent +tests: + - it: should not render default harnesses unless enabled + asserts: + - hasDocuments: + count: 0 + + - it: should render one harness per built-in runtime when enabled + set: + harnesses.enabled: true + asserts: + - hasDocuments: + count: 3 + - isKind: + of: Harness + - isAPIVersion: + of: api.kagent.dev/v1alpha3 + - equal: + path: metadata.name + value: kagent-default + documentIndex: 0 + - exists: + path: spec.kagent + documentIndex: 0 + - equal: + path: metadata.name + value: codex-default + documentIndex: 1 + - exists: + path: spec.codex + documentIndex: 1 + - equal: + path: metadata.name + value: claude-default + documentIndex: 2 + - exists: + path: spec.claude + documentIndex: 2 + + - it: should run on the chart worker pool with the default snapshot location and built-in image + set: + harnesses.enabled: true + asserts: + - equal: + path: metadata.namespace + value: kagent + - equal: + path: spec.substrate.workerPoolRef.name + value: kagent-default + - equal: + path: spec.substrate.snapshotPolicy.location + value: s3://ate-snapshots/kagent/ + - notExists: + path: spec.workload + - equal: + path: metadata.labels["app.kubernetes.io/managed-by"] + value: Helm + + - it: should follow a renamed worker pool and custom snapshot location + set: + harnesses.enabled: true + harnesses.snapshotLocation: s3://team-snapshots/agents/ + substrateWorkerPool.name: team-pool + asserts: + - equal: + path: spec.substrate.workerPoolRef.name + value: team-pool + - equal: + path: spec.substrate.snapshotPolicy.location + value: s3://team-snapshots/agents/ + + - it: should skip a disabled runtime + set: + harnesses.enabled: true + harnesses.claude.enabled: false + asserts: + - hasDocuments: + count: 2 + - notEqual: + path: metadata.name + value: claude-default + + - it: should scope the harnesses to the namespace override + set: + harnesses.enabled: true + namespaceOverride: tenant-a + asserts: + - equal: + path: metadata.namespace + value: tenant-a + + - it: should require a snapshot location + set: + harnesses.enabled: true + harnesses.snapshotLocation: "" + asserts: + - failedTemplate: + errorMessage: harnesses.snapshotLocation is required when harnesses.enabled=true diff --git a/helm/kagent/tests/modelconfig_test.yaml b/helm/kagent/tests/modelconfig_test.yaml index 1362c086dc..47451dd22d 100644 --- a/helm/kagent/tests/modelconfig_test.yaml +++ b/helm/kagent/tests/modelconfig_test.yaml @@ -14,7 +14,7 @@ tests: value: "OpenAI" - equal: path: spec.model - value: "gpt-4.1-mini" + value: "gpt-5-mini" - it: should use openai configuration by default asserts: @@ -23,13 +23,16 @@ tests: value: "OpenAI" - equal: path: spec.model - value: "gpt-4.1-mini" + value: "gpt-5-mini" - equal: path: spec.apiKeySecret value: kagent-openai - equal: path: spec.apiKeySecretKey value: OPENAI_API_KEY + - equal: + path: spec.openAI.apiFormat + value: responses - it: should use anthropic when set as default provider set: @@ -59,13 +62,18 @@ tests: value: "AzureOpenAI" - equal: path: spec.model - value: "gpt-4.1-mini" + value: "gpt-5-mini" - equal: path: spec.apiKeySecret value: kagent-azure-openai - equal: path: spec.apiKeySecretKey value: AZUREOPENAI_API_KEY + - equal: + path: spec.azureOpenAI.apiVersion + value: "2024-10-21" + - notExists: + path: spec.azureOpenAI.apiFormat - it: should configure ollama provider set: diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 69c5b77417..ff11636817 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -161,6 +161,8 @@ config: # Opt in to Kubernetes TokenReview authentication independently of the mode. # serviceAccount: # authority: kubernetes + # Serve gRPC reflection for tools such as grpcurl. Callers must pass the + # configured authentication. `kagent install` enables it. grpcReflection: false database: # Role each login assumes after connecting. Set distinct roles for each install sharing a database. @@ -430,6 +432,30 @@ substrateWorkerPool: # nodeSelector / tolerations / priorityClassName / nodeAffinity / resources. template: {} +# -- Ready-to-use Harnesses in the release namespace, so Agents there can set +# spec.harnessRef without first writing a Harness. Each runs on +# substrateWorkerPool.name and uses the release's built-in runtime image +# (config.harnessImages), so the chart does not pin an image here. `kagent install` +# enables them together with the WorkerPool. Agents in other namespaces need +# their own Harness, because Agent.spec.harnessRef is namespace-local. +harnesses: + # -- Render the default Harnesses. Requires a WorkerPool named + # substrateWorkerPool.name in the release namespace (substrateWorkerPool.create + # or one managed outside the chart). + enabled: false + # -- Substrate snapshot location shared by the default Harnesses. The default + # matches the ate-snapshots bucket of Substrate's bundled RustFS. + snapshotLocation: s3://ate-snapshots/kagent/ + # -- Render `kagent-default`, a Harness for the kagent runtime. + kagent: + enabled: true + # -- Render `codex-default`, a Harness for the Codex runtime. + codex: + enabled: true + # -- Render `claude-default`, a Harness for the Claude runtime. + claude: + enabled: true + # ============================================================================== # UI CONFIGURATION # ============================================================================== @@ -667,9 +693,13 @@ providers: annotations: {} openAI: provider: OpenAI - model: "gpt-4.1-mini" + model: "gpt-5-mini" apiKeySecretRef: kagent-openai apiKeySecretKey: OPENAI_API_KEY + # Provider options rendered into spec.openAI of the generated ModelConfig. + # The Codex runtime requires the Responses API; the kagent runtime supports both. + config: + apiFormat: responses # apiKey: "" # Optional TLS settings for the generated ModelConfig. # This same block can be used under any provider. @@ -707,12 +737,12 @@ providers: # cacheTTL: "5m" # or "1h" for calls spaced more than 5 minutes apart azureOpenAI: provider: AzureOpenAI - model: "gpt-4.1-mini" + model: "gpt-5-mini" apiKeySecretRef: kagent-azure-openai apiKeySecretKey: AZUREOPENAI_API_KEY # apiKey: "" config: - apiVersion: "2023-05-15" + apiVersion: "2024-10-21" azureAdToken: "" azureDeployment: "" azureEndpoint: "" diff --git a/proto/kagent/api/v1/harnesses.proto b/proto/kagent/api/v1/harnesses.proto index 9ab5bf296f..71c6b9fe1a 100644 --- a/proto/kagent/api/v1/harnesses.proto +++ b/proto/kagent/api/v1/harnesses.proto @@ -2,6 +2,7 @@ syntax = "proto3"; package kagent.api.v1; +import "buf/validate/validate.proto"; import "kagent/api/v1/common.proto"; option go_package = "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1;apiv1"; @@ -12,6 +13,9 @@ option go_package = "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1;apiv1 service HarnessService { rpc ListHarnesses(ListHarnessesRequest) returns (ListHarnessesResponse); rpc CreateHarness(CreateHarnessRequest) returns (CreateHarnessResponse); + // UpdateHarness replaces the spec and labels of an existing Harness. Agents + // that reference it pick up the change on their next reconcile. + rpc UpdateHarness(UpdateHarnessRequest) returns (UpdateHarnessResponse); rpc DeleteHarness(DeleteHarnessRequest) returns (DeleteHarnessResponse); } @@ -27,7 +31,8 @@ message Harness { // would otherwise each reimplement the exactly-one-of check the CRD enforces. string runtime = 3; - // WorkloadImage is spec.workload.image, the digest-pinned runtime image. + // WorkloadImage is spec.workload.image, the digest-pinned runtime image. Empty + // when the spec leaves it to the controller's release default. string workload_image = 4; } @@ -48,6 +53,15 @@ message CreateHarnessResponse { Harness harness = 1; } +message UpdateHarnessRequest { + ResourceReference ref = 1 [(buf.validate.field).required = true]; + StructuredObject resource = 2 [(buf.validate.field).required = true]; +} + +message UpdateHarnessResponse { + Harness harness = 1; +} + message DeleteHarnessRequest { ResourceReference ref = 1; } diff --git a/proto/kagent/api/v1/system.proto b/proto/kagent/api/v1/system.proto index 0808dba2ae..4551590f4e 100644 --- a/proto/kagent/api/v1/system.proto +++ b/proto/kagent/api/v1/system.proto @@ -17,6 +17,10 @@ service SystemService { rpc GetSubstrateSummary(GetSubstrateSummaryRequest) returns (GetSubstrateSummaryResponse); rpc ListSubstrateActors(ListSubstrateActorsRequest) returns (ListSubstrateActorsResponse); rpc ListSubstrateWorkers(ListSubstrateWorkersRequest) returns (ListSubstrateWorkersResponse); + // ListWorkerPools reads WorkerPool resources from Kubernetes only. A Harness + // names a pool in its own namespace, so forms use this rather than the summary, + // which walks every ate-api actor and worker. + rpc ListWorkerPools(ListWorkerPoolsRequest) returns (ListWorkerPoolsResponse); } message GetVersionRequest {} @@ -134,3 +138,16 @@ message ListSubstrateWorkersResponse { PageResponse page = 4; google.protobuf.Timestamp computed_at = 5; } + +message ListWorkerPoolsRequest { + // Kubernetes namespace of the pools. Empty uses watched namespaces. + string namespace = 1 [(buf.validate.field).string = { + max_len: 63 + pattern: "^([a-z0-9]([-a-z0-9]*[a-z0-9])?)?$" + }]; +} + +message ListWorkerPoolsResponse { + // Pools the caller may read, in namespace/name order. + repeated SubstrateWorkerPool worker_pools = 1; +} diff --git a/scripts/get-kagent b/scripts/get-kagent index 9c52ebe028..bcc946a07e 100755 --- a/scripts/get-kagent +++ b/scripts/get-kagent @@ -23,13 +23,14 @@ : ${VERIFY_CHECKSUM:="true"} : ${KAGENT_INSTALL_DIR:="/usr/local/bin"} +KAGENT_RELEASES="https://github.com/kagent-dev/kagent/releases" + HAS_CURL="$(type "curl" &> /dev/null && echo true || echo false)" HAS_WGET="$(type "wget" &> /dev/null && echo true || echo false)" HAS_OPENSSL="$(type "openssl" &> /dev/null && echo true || echo false)" HAS_GPG="$(type "gpg" &> /dev/null && echo true || echo false)" HAS_GIT="$(type "git" &> /dev/null && echo true || echo false)" HAS_TAR="$(type "tar" &> /dev/null && echo true || echo false)" -HAS_JQ="$(type "jq" &> /dev/null && echo true || echo false)" # initArch discovers the architecture for this system. initArch() { @@ -52,8 +53,12 @@ initOS() { case "$OS" in # Minimalist GNU for Windows - mingw*|cygwin*) OS='windows';; + mingw*|cygwin*|msys*) OS='windows';; esac + EXT="" + if [ "$OS" == "windows" ]; then + EXT=".exe" + fi } # runs the given command as root (detects if we are root already) @@ -68,12 +73,15 @@ runAsRoot() { # verifySupported checks that the os/arch combination is supported for # binary builds, as well whether or not necessary tools are present. verifySupported() { - local supported="darwin-amd64\ndarwin-arm64\nlinux-386\nlinux-amd64\nlinux-arm\nlinux-arm64\nlinux-ppc64le\nlinux-s390x\nlinux-riscv64\nwindows-amd64\nwindows-arm64" - if ! echo "${supported}" | grep -q "${OS}-${ARCH}"; then - echo "No prebuilt binary for ${OS}-${ARCH}." - echo "To build from source, go to https://github.com/kagent-dev/kagent" - exit 1 - fi + # Keep in sync with the release binaries built by go/Makefile. + case "${OS}-${ARCH}" in + darwin-amd64|darwin-arm64|linux-amd64|linux-arm64|windows-amd64) ;; + *) + echo "No prebuilt binary for ${OS}-${ARCH}." + echo "To build from source, go to https://github.com/kagent-dev/kagent" + exit 1 + ;; + esac if [ "${HAS_CURL}" != "true" ] && [ "${HAS_WGET}" != "true" ]; then echo "Either curl or wget is required" @@ -94,44 +102,45 @@ verifySupported() { echo "[ERROR] Could not find tar. It is required to extract the kagent binary archive." exit 1 fi +} - if [ "${HAS_JQ}" != "true" ]; then - echo "[ERROR] Could not find jq. It is required to parse the kagent version." - exit 1 - fi +# latestTag prints the tag of the release GitHub marks latest, read from the +# redirect of /releases/latest. Unlike the GitHub API, it is not rate limited. +latestTag() { + if [ "${HAS_CURL}" == "true" ]; then + curl -sSI "${KAGENT_RELEASES}/latest" 2>/dev/null + else + wget -S --spider --max-redirect=0 "${KAGENT_RELEASES}/latest" 2>&1 + fi | tr -d '\r' | sed -n 's#^ *[Ll]ocation: .*/releases/tag/##p' | head -n 1 } -# checkDesiredVersion checks if the desired version is available. +# checkDesiredVersion resolves TAG: the --version argument, or else the release +# GitHub marks latest. checkDesiredVersion() { - if [ "x$DESIRED_VERSION" == "x" ]; then - # Get tag from release URL - local latest_release_url="https://api.github.com/repos/kagent-dev/kagent/releases/latest" - local latest_release_response="" - if [ "${HAS_CURL}" == "true" ]; then - latest_release_response=$( curl -L --silent --show-error --fail "$latest_release_url" 2>&1 || true ) - elif [ "${HAS_WGET}" == "true" ]; then - latest_release_response=$( wget "$latest_release_url" -q -O - 2>&1 || true ) - fi - TAG=$( echo "$latest_release_response" | jq -r .tag_name | grep '^v[0-9]' ) - if [ "x$TAG" == "x" ]; then - printf "Could not retrieve the latest release tag information from %s: %s\n" "${latest_release_url}" "${latest_release_response}" - exit 1 - fi - else + if [ "x$DESIRED_VERSION" != "x" ]; then TAG=$DESIRED_VERSION + return + fi + TAG=$( latestTag ) + if [ "x$TAG" == "x" ]; then + echo "Could not resolve the latest kagent release from ${KAGENT_RELEASES}/latest." + echo "Use --version to choose a release." + exit 1 fi } -# checkKagentInstalledVersion checks which version of is installed and -# if it needs to be changed. +# checkKagentInstalledVersion checks which version of kagent is installed and +# if it needs to be changed. It asks only the CLI, never a kagent server. checkKagentInstalledVersion() { - if [[ -f "${KAGENT_INSTALL_DIR}/${BINARY_NAME}" ]]; then - local version=$("${KAGENT_INSTALL_DIR}/${BINARY_NAME}" version | jq -r '.kagent_version') + if [[ -f "${KAGENT_INSTALL_DIR}/${BINARY_NAME}${EXT}" ]]; then + local version + version=$( "${KAGENT_INSTALL_DIR}/${BINARY_NAME}${EXT}" version --client 2>/dev/null \ + | sed -n 's/.*"kagent_version": *"\([^"]*\)".*/\1/p' || true ) if [[ "$version" == "$TAG" ]]; then echo "kagent ${version} is already ${DESIRED_VERSION:-latest}" return 0 else - echo "kagent ${TAG} is available. Changing from version ${version}." + echo "kagent ${TAG} is available. Changing from version ${version:-unknown}." return 1 fi else @@ -142,16 +151,16 @@ checkKagentInstalledVersion() { # downloadFile downloads the latest binary package and also the checksum # for that binary. downloadFile() { - KAGENT_DIST="kagent-$OS-$ARCH" - DOWNLOAD_URL="https://github.com/kagent-dev/kagent/releases/download/$TAG/$KAGENT_DIST" + KAGENT_DIST="kagent-$OS-$ARCH$EXT" + DOWNLOAD_URL="$KAGENT_RELEASES/download/$TAG/$KAGENT_DIST" CHECKSUM_URL="$DOWNLOAD_URL.sha256" KAGENT_TMP_ROOT="$(mktemp -dt kagent-installer-XXXXXX)" KAGENT_TMP_FILE="$KAGENT_TMP_ROOT/$KAGENT_DIST" KAGENT_SUM_FILE="$KAGENT_TMP_ROOT/$KAGENT_DIST.sha256" echo "Downloading $DOWNLOAD_URL" if [ "${HAS_CURL}" == "true" ]; then - curl -SsL "$CHECKSUM_URL" -o "$KAGENT_SUM_FILE" - curl -SsL "$DOWNLOAD_URL" -o "$KAGENT_TMP_FILE" + curl -SsLf "$CHECKSUM_URL" -o "$KAGENT_SUM_FILE" + curl -SsLf "$DOWNLOAD_URL" -o "$KAGENT_TMP_FILE" elif [ "${HAS_WGET}" == "true" ]; then wget -q -O "$KAGENT_SUM_FILE" "$CHECKSUM_URL" wget -q -O "$KAGENT_TMP_FILE" "$DOWNLOAD_URL" @@ -170,9 +179,9 @@ verifyFile() { # installFile installs the kagent binary. installFile() { echo "Preparing to install $BINARY_NAME into ${KAGENT_INSTALL_DIR}" - runAsRoot chmod +x "$KAGENT_TMP_ROOT/$BINARY_NAME-$OS-$ARCH" - runAsRoot cp "$KAGENT_TMP_ROOT/$BINARY_NAME-$OS-$ARCH" "$KAGENT_INSTALL_DIR/$BINARY_NAME" - echo "$BINARY_NAME installed into $KAGENT_INSTALL_DIR/$BINARY_NAME" + runAsRoot chmod +x "$KAGENT_TMP_FILE" + runAsRoot cp "$KAGENT_TMP_FILE" "$KAGENT_INSTALL_DIR/$BINARY_NAME$EXT" + echo "$BINARY_NAME installed into $KAGENT_INSTALL_DIR/$BINARY_NAME$EXT" } # verifyChecksum verifies the SHA256 checksum of the binary package. @@ -206,7 +215,7 @@ fail_trap() { # testVersion tests the installed client to make sure it is working. testVersion() { set +e - KAGENT="$(command -v $BINARY_NAME)" + KAGENT="$(command -v "$BINARY_NAME$EXT")" if [ "$?" = "1" ]; then echo "$BINARY_NAME not found. Is $KAGENT_INSTALL_DIR on your "'$PATH?' exit 1 @@ -218,8 +227,8 @@ testVersion() { help () { echo "Accepted cli arguments are:" echo -e "\t[--help|-h ] ->> prints this help" - echo -e "\t[--version|-v ] . When not defined it fetches the latest release tag from the kagent GitHub repository" - echo -e "\te.g. --version v3.0.0 or -v canary" + echo -e "\t[--version|-v ] . When not defined it installs the release GitHub marks latest" + echo -e "\te.g. --version v1.0.0" echo -e "\t[--no-sudo] ->> install without sudo (installs to \$HOME/bin instead of /usr/local/bin)" } @@ -269,7 +278,7 @@ while [[ $# -gt 0 ]]; do export DESIRED_VERSION="v${1}" fi else - echo -e "Please provide the desired version. e.g. --version v0.1.0 or -v canary" + echo -e "Please provide the desired version. e.g. --version v0.1.0" exit 0 fi ;; diff --git a/scripts/get-kagent_test.sh b/scripts/get-kagent_test.sh new file mode 100755 index 0000000000..5978c33fed --- /dev/null +++ b/scripts/get-kagent_test.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# Tests scripts/get-kagent against a fake GitHub: curl answers the +# /releases/latest redirect and serves binaries from a fixture directory, and +# uname reports the platform under test. +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +releases="https://github.com/kagent-dev/kagent/releases" +downloads="$releases/download" +fake_bin="$work/bin" +mkdir -p "$fake_bin" + +cat >"$fake_bin/uname" <<'EOF' +#!/usr/bin/env bash +if [ "${1:-}" = "-m" ]; then echo "$FAKE_ARCH"; else echo "$FAKE_OS"; fi +EOF + +# curl redirects /releases/latest to the tag in $FIXTURE/latest, prints or +# saves fixture files, and logs each requested URL. +cat >"$fake_bin/curl" <<'EOF' +#!/usr/bin/env bash +output="" url="" +while [ $# -gt 0 ]; do + case "$1" in + -o) output="$2"; shift ;; + -*) ;; + *) url="$1" ;; + esac + shift +done +echo "$url" >>"$FIXTURE/requests" +case "$url" in + "$RELEASES/latest") + if [ -f "$FIXTURE/latest" ]; then + printf 'HTTP/2 302\r\nlocation: %s/tag/%s\r\n\r\n' "$RELEASES" "$(cat "$FIXTURE/latest")" + else + printf 'HTTP/2 404\r\n\r\n' + fi + exit 0 ;; + "$RELEASES/download/"*) file="$FIXTURE/download/${url#"$RELEASES/download/"}" ;; + *) exit 22 ;; +esac +[ -f "$file" ] || exit 22 +if [ -n "$output" ]; then cp "$file" "$output"; else cat "$file"; fi +EOF +chmod +x "$fake_bin/uname" "$fake_bin/curl" + +# publish TAG ASSET creates a downloadable binary and checksum. The binary +# answers only `version --client`; anything else records a server call. +publish() { + local dir="$fixture/download/$1" + mkdir -p "$dir" + write_binary "$dir/$2" "$1" + echo "$(openssl sha1 -sha256 "$dir/$2" | awk '{print $2}') $2" >"$dir/$2.sha256" +} + +write_binary() { + cat >"$1" <"$fixture/requests" +} + +# run_installer OS ARCH ARGS... runs get-kagent and saves its output. +run_installer() { + local os="$1" arch="$2" + shift 2 + set +e + PATH="$fake_bin:$install_dir:$PATH" FIXTURE="$fixture" RELEASES="$releases" \ + FAKE_OS="$os" FAKE_ARCH="$arch" USE_SUDO=false KAGENT_INSTALL_DIR="$install_dir" \ + bash "$root/scripts/get-kagent" "$@" >"$fixture/output" 2>&1 + status=$? + set -e +} + +fail() { + echo "FAIL: $scenario: $*" >&2 + echo "--- installer output ---" >&2 + cat "$fixture/output" >&2 + exit 1 +} + +assert_installed() { + local binary="$1" want="$2" + [ "$status" -eq 0 ] || fail "installer exited $status" + [ -x "$install_dir/$binary" ] || fail "$binary was not installed" + local got + got="$("$install_dir/$binary" version --client)" + [[ "$got" == *"\"kagent_version\":\"$want\""* ]] || fail "installed $got, want $want" +} + +scenario="Given GitHub marks a release candidate latest, when installing without --version, assert that the release candidate is installed" +new_fixture latest +echo v1.0.0-rc3 >"$fixture/latest" +publish v1.0.0-rc3 kagent-linux-amd64 +publish v0.10.7 kagent-linux-amd64 +run_installer Linux x86_64 +assert_installed kagent v1.0.0-rc3 + +scenario="Given a requested version, when installing with --version, assert that it is installed without resolving latest" +new_fixture pinned +echo v1.0.0-rc3 >"$fixture/latest" +publish v0.10.7 kagent-linux-arm64 +run_installer Linux aarch64 --version 0.10.7 +assert_installed kagent v0.10.7 +! grep -qx "$releases/latest" "$fixture/requests" || fail "resolved latest despite --version" + +scenario="Given latest cannot be resolved, when installing without --version, assert that the installer fails and suggests --version" +new_fixture no-latest +run_installer Linux x86_64 +[ "$status" -ne 0 ] || fail "installer succeeded" +grep -q -- "--version" "$fixture/output" || fail "output does not suggest --version" + +scenario="Given latest lacks this platform's binary, when installing, assert that the installer fails without installing" +new_fixture missing-asset +echo v1.0.0 >"$fixture/latest" +publish v1.0.0 kagent-darwin-arm64 +run_installer Linux x86_64 +[ "$status" -ne 0 ] || fail "installer succeeded" +[ ! -e "$install_dir/kagent" ] || fail "installed a binary" + +scenario="Given a Windows shell, when installing, assert that the .exe binary is downloaded and installed" +new_fixture windows +echo v1.0.0 >"$fixture/latest" +publish v1.0.0 kagent-windows-amd64.exe +run_installer MINGW64_NT-10.0 x86_64 +assert_installed kagent.exe v1.0.0 +grep -qx "$downloads/v1.0.0/kagent-windows-amd64.exe" "$fixture/requests" || fail "did not download the .exe asset" + +scenario="Given an unpublished architecture, when installing, assert that the installer reports no prebuilt binary" +new_fixture unsupported +run_installer Linux i686 +[ "$status" -ne 0 ] || fail "installer succeeded" +grep -q "No prebuilt binary for linux-386" "$fixture/output" || fail "missing unsupported-platform message" + +scenario="Given latest is installed, when installing again, assert that nothing is downloaded and no server is contacted" +new_fixture already-installed +echo v1.0.0 >"$fixture/latest" +write_binary "$install_dir/kagent" v1.0.0 +run_installer Linux x86_64 +[ "$status" -eq 0 ] || fail "installer exited $status" +grep -q "kagent v1.0.0 is already latest" "$fixture/output" || fail "did not detect the installed version" +! grep -q "/releases/download/" "$fixture/requests" || fail "downloaded despite a matching install" +[ ! -e "$fixture/server-contacted" ] || fail "version check contacted the server" + +echo "get-kagent tests passed" diff --git a/ui/playwright/README.md b/ui/playwright/README.md index 2a4b01bb12..5bdb9bfba0 100644 --- a/ui/playwright/README.md +++ b/ui/playwright/README.md @@ -75,9 +75,9 @@ playwright/ ``` The resources with a lifecycle spec are **models**, **MCP servers**, **prompt -libraries**, **agent templates**, **harnesses** and **schedules**. Two are narrower +libraries**, **agent templates**, **harnesses** and **schedules**. One is narrower than CRUD because the product is: an MCP server's address is its identity, so -`ToolService` serves no update, and the harnesses tab offers no edit. +`ToolService` serves no update. **Agents are not among them, because an agent is not created.** An agent is an `AgentTemplate` paired with a `Harness` — it exists the moment a harness admits a diff --git a/ui/playwright/helpers/app.ts b/ui/playwright/helpers/app.ts index ffbf2ec1f9..0465025589 100644 --- a/ui/playwright/helpers/app.ts +++ b/ui/playwright/helpers/app.ts @@ -59,6 +59,8 @@ export const agents = { sharedOnFastLane: { name: "shared-brain-fast", template: "shared-brain", harness: "fast-lane" }, /** Defined, but with no successful revision — so no conversation can start. */ preparing: { name: "support-triage-2b91d0e", template: "support-triage-2b91d0e", harness: "support-triage" }, + /** Ran once; its latest edit names a Harness image that cannot be pulled. */ + updateFailed: { name: "triage-on-claude", template: "support-triage-2b91d0e", harness: "support-triage" }, } as const; /** Where one agent lives: its namespace and name. */ diff --git a/ui/playwright/tests/agents/agent-crud.spec.ts b/ui/playwright/tests/agents/agent-crud.spec.ts index 7d3d79a8ab..904effca8b 100644 --- a/ui/playwright/tests/agents/agent-crud.spec.ts +++ b/ui/playwright/tests/agents/agent-crud.spec.ts @@ -23,30 +23,32 @@ for (const template of CASES) { await expect(page).toHaveURL(/\/agents\/new$/); await expect(page.getByTestId("agent-form-namespace")).toContainText("kagent"); const submit = page.getByTestId("agent-form-submit"); - const problems = page.getByTestId("agent-form-problems"); + const problems = page.getByTestId("agent-form-problems").locator("li"); await expect(submit).toBeDisabled(); - await expect(problems).toHaveText("A name is required."); + // Every problem at once, so nothing surprises the user after fixing the first; the + // harness is not among them because the namespace's default one is preselected. + await expect(problems).toHaveText(["A name is required.", "Choose a template."]); + await expect(page.getByTestId("agent-form-harness-ref")).toContainText("kagent-default"); await expectRequired(page, { marked: ["Name", "Namespace", "Shared template", "Shared harness"], unmarked: [], }); await page.getByTestId("agent-form-name").fill(name); await expect(submit).toBeDisabled(); - await expect(problems).toHaveText("Choose a template."); + await expect(problems).toHaveText(["Choose a template."]); if (template === "inline") { await chooseTemplateSource(page, "inline"); await expect(submit).toBeDisabled(); - await expect(problems).toHaveText("Choose a model configuration for the inline template."); + await expect(problems).toHaveText(["Choose a model configuration for the inline template."]); await selectOption(page, "template-form-model", "kagent/default-model-config"); await page.getByTestId("template-form-description").fill("Inline before edit"); await page.getByTestId("template-form-prompt").fill("You answer briefly."); } else { await selectOption(page, "agent-form-template-ref", "shared-brain"); } - await expect(submit).toBeDisabled(); - await expect(problems).toHaveText("Choose a harness."); + await expect(submit).toBeEnabled(); await selectOption(page, "agent-form-harness-ref", "k8s-agent"); await expect(problems).toHaveCount(0); await expect(submit).toBeEnabled(); diff --git a/ui/playwright/tests/agents/agent-page.spec.ts b/ui/playwright/tests/agents/agent-page.spec.ts index 8a7b91d428..9624cc0647 100644 --- a/ui/playwright/tests/agents/agent-page.spec.ts +++ b/ui/playwright/tests/agents/agent-page.spec.ts @@ -403,6 +403,31 @@ test("agents: a conversation is created by its first message, not by the click", }); }); +test("agents: a failed edit says why on the agent's page, not only in a tooltip", async ({ page }) => { + /* + * A typo in a Harness image used to surface only as "Update failed" with the + * controller's message on hover. Nothing is blocked, because conversations keep the + * last good revision, so `agent-cannot-start` never appears; this alert is the + * only place on the page that says what went wrong. + */ + await loadPage(page, agentPage(agents.updateFailed)); + await expectSettled(page); + + const failed = page.getByTestId("agent-update-failed"); + await expect(failed).toBeVisible(); + await expect(failed).toContainText("could not pull image ghcr.io/kagent-dev/kagent/claude-harnes:0.9.0"); + await expect(failed).toContainText("last good revision"); + await expect(page.getByTestId("agent-cannot-start")).toHaveCount(0); + await expect(page.getByTestId("agent-revision-state")).toHaveAttribute("data-revision-state", "updateFailed"); +}); + +test("agents: a ready agent shows no failure alert", async ({ page }) => { + await loadPage(page, agentPage(agents.k8s)); + await expectSettled(page); + await expect(page.getByTestId("agent-revision-state")).toHaveAttribute("data-revision-state", "ready"); + await expect(page.getByTestId("agent-update-failed")).toHaveCount(0); +}); + test("agents: deleting an Agent preserves shared resources and conversations", async ({ page }) => { await loadPage(page, agentPage(agents.k8s)); await expect(dataRows(page).first()).toBeVisible({ timeout: 30_000 }); diff --git a/ui/playwright/tests/agents/agents.spec.ts b/ui/playwright/tests/agents/agents.spec.ts index e3ef8e6b1a..b1dee0980b 100644 --- a/ui/playwright/tests/agents/agents.spec.ts +++ b/ui/playwright/tests/agents/agents.spec.ts @@ -10,16 +10,37 @@ import { routes, withScenario, } from "../../helpers/app"; -import { clickRefresh, optionNamed, pressOnce } from "../../helpers/resource"; +import { anyDialog, clickRefresh, dialog, optionNamed, pressOnce } from "../../helpers/resource"; import { operationCalls, rpc } from "../../helpers/mockCalls"; import { background, settledPaint } from "../../helpers/style"; -test("agents: an empty Harness selector links to Harness creation", async ({ page }) => { +test("agents: an empty Harness selector offers Harness creation in place", async ({ page }) => { await loadPage(page, routes.agentNew, { scenario: "empty", title: "New agent" }); await page.getByTestId("agent-form-harness-ref").click(); - const createHarness = page.getByRole("link", { name: "Create a harness" }); - await expect(createHarness).toHaveAttribute("href", routes.harnessNew); + await page.getByRole("button", { name: "Create a harness" }).click(); + await expect(dialog(page)).toBeVisible(); + await expect(dialog(page).getByTestId("harness-intro")).toBeVisible(); + await expect(dialog(page).getByTestId("harness-create")).toBeDisabled(); +}); + +test("agents: a harness made from the agent form is selected for the agent", async ({ page }) => { + await loadPage(page, routes.agentNew, { title: "New agent" }); + // The namespace's default harness is preselected, so a fresh agent needs no choice. + await expect(page.getByTestId("agent-form-harness-ref")).toContainText("kagent-default"); + + await page.getByTestId("agent-form-harness-create").click(); + const form = dialog(page); + await expect(form).toBeVisible(); + await expect(form.getByTestId("harness-namespace")).toContainText("kagent"); + await form.getByTestId("harness-name").fill("made-inline"); + await expect(form.getByTestId("harness-worker-pool").locator("input")).toHaveValue("kagent-default"); + await form.getByTestId("harness-create").click(); + + await expect(anyDialog(page)).toBeHidden(); + await expect(page.getByTestId("agent-form-harness-ref")).toContainText("made-inline"); + // A dialog rather than a trip to the harnesses page, so the agent form is still here. + await expect(page).toHaveURL(/\/agents\/new(\?|$)/); }); /** Agents -- each row is one explicit Agent with behavior and a referenced Harness. */ @@ -60,13 +81,17 @@ test("agents: the list is Agent resources with explicit template and harness sou await expect(rowNamed(page, "note-taker")).toHaveCount(0); }); - await test.step("5. status is ready, preparing or not reported", async () => { + await test.step("5. status is ready, preparing, update failed or not reported", async () => { await expect( page.getByTestId(`agent-revision-kagent/${agents.k8s.name}`), ).toHaveAttribute("data-revision-state", "ready"); const preparing = page.getByTestId(`agent-revision-kagent/${agents.preparing.name}`); await expect(preparing).toHaveAttribute("data-revision-state", "preparing"); await expect(preparing).toHaveText("Preparing"); + // The list keeps the tag; the controller's message is on the agent's own page. + const updateFailed = page.getByTestId(`agent-revision-kagent/${agents.updateFailed.name}`); + await expect(updateFailed).toHaveAttribute("data-revision-state", "updateFailed"); + await expect(page.getByTestId("agent-update-failed")).toHaveCount(0); }); await test.step("6. conversation counts are per Agent, not per template", async () => { diff --git a/ui/playwright/tests/harnesses/harnesses.spec.ts b/ui/playwright/tests/harnesses/harnesses.spec.ts index 9478711e0e..52d9dceae0 100644 --- a/ui/playwright/tests/harnesses/harnesses.spec.ts +++ b/ui/playwright/tests/harnesses/harnesses.spec.ts @@ -1,10 +1,10 @@ import { test, expect } from "../../fixtures/test"; -import { loadPage, routes } from "../../helpers/app"; +import { loadPage, rowNamed, routes } from "../../helpers/app"; import { LIFECYCLE_TIMEOUT, confirmDelete, expectRequired, - selectFirstOption, + selectOption, } from "../../helpers/resource"; /** @@ -13,21 +13,24 @@ import { * One test, because a video and a trace are recorded per *test* — see * `playwright/README.md`. * - * **There is no update half.** The tab offers create and delete and no edit, so the - * journey is create, read back, remove. That is narrower than `HarnessService`, which - * implements update too — this application has never called it. + * The journey is create, read back, edit, read back again, remove: every call the tab + * makes to `HarnessService`. * * ## Why the form is short * * The CRD is strict, and the constraints the form enforces are the cluster's rather than - * this page's: exactly one runtime adapter, an image pinned by digest, and a worker pool - * for the Substrate Actors to be scheduled onto. A form that accepted a tag would build a - * resource the cluster rejects — the failure that is invisible until somebody tries it - * for real, which is why the fixture refuses it too. + * this page's: exactly one runtime adapter, an image (when one is set) pinned by digest, + * a worker pool for the Substrate Actors to be scheduled onto and a snapshot location. A + * form that accepted a tag would build a resource the cluster rejects — the failure that + * is invisible until somebody tries it for real, which is why the fixture refuses it too. + * The defaults fill in what a fresh install has: the release's built-in image, the + * chart's `kagent-default` pool and a snapshot path. */ /** The one this journey makes, reads back and removes. */ const CREATED = "made-here"; +const CREATED_REF = `kagent/${CREATED}`; +const PINNED = `ghcr.io/example/runtime@sha256:${"a".repeat(64)}`; /** Its rows, which is the surface that can say whether any of this happened. */ const table = "harnesses-table"; @@ -39,11 +42,12 @@ const table = "harnesses-table"; */ test.describe.configure({ timeout: LIFECYCLE_TIMEOUT }); -test("harnesses: a harness is created, read and deleted", async ({ page }) => { +test("harnesses: a harness is created, read, edited and deleted", async ({ page }) => { await test.step("1. the harnesses are listed", async () => { await loadPage(page, routes.harnesses, { title: "Agents" }); await expect(page.getByTestId(table)).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId(table).getByText("k8s-agent", { exact: true })).toBeVisible(); + await expect(page.getByTestId(table).getByText("kagent-default", { exact: true })).toBeVisible(); await expect(page.getByTestId(table).getByRole("columnheader", { name: "Ready", exact: true })).toHaveCount(0); }); @@ -62,50 +66,44 @@ test("harnesses: a harness is created, read and deleted", async ({ page }) => { await expect.poll(() => rows.count()).toBe(before); }); - await test.step("3. the form requires a complete runtime configuration", async () => { + await test.step("3. the form explains itself and marks what the cluster requires", async () => { await page.getByTestId("agents-new-harness").click(); await page.waitForURL(/\/harnesses\/new(\?|$)/); + await expect(page.getByTestId("harness-intro")).toContainText("worker pool"); await expectRequired(page, { - marked: [ - "Namespace", - "Name", - "Runtime adapter", - "Workload image", - "Worker pool", - "Snapshot location", - ], - unmarked: [], + marked: ["Namespace", "Name", "Runtime adapter", "Worker pool", "Snapshot location"], + // The release ships an image for every built-in adapter; only bring-your-own needs one. + unmarked: ["Workload image"], }); }); - await test.step("4. an image that is not pinned cannot be submitted", async () => { - // Any namespace will do: this step is about the image, not about where the harness - // lives. - await selectFirstOption(page, "harness-namespace"); + await test.step("4. the namespace's default pool and a snapshot path are filled in", async () => { + await selectOption(page, "harness-namespace", "kagent"); await page.getByTestId("harness-name").fill(CREATED); - await page.getByTestId("harness-worker-pool").fill("kagent-default"); + await expect(page.getByTestId("harness-worker-pool").locator("input")).toHaveValue("kagent-default"); + await expect(page.getByTestId("harness-snapshot")).toHaveValue("s3://ate-snapshots/kagent/"); + }); + + await test.step("5. an image that is not pinned cannot be submitted", async () => { await page.getByTestId("harness-image").fill("ghcr.io/example/runtime:latest"); await expect( page.getByTestId("harness-create"), "a tag can move under a running agent, and the CRD refuses one", ).toBeDisabled(); + await page.getByTestId("harness-image").fill(""); }); - await test.step("5. nor can one with no snapshot location", async () => { - // The CRD requires it. This form used to treat it as optional, so a harness could be - // submitted without one and the controller answered "Invalid Harness" — naming - // neither the field nor what was wrong with it. - await page - .getByTestId("harness-image") - .fill(`ghcr.io/example/runtime@sha256:${"a".repeat(64)}`); + await test.step("6. nor can one with no snapshot location", async () => { + // The CRD requires it, and the controller's answer to a missing one is "Invalid + // Harness" — naming neither the field nor what was wrong with it. + await page.getByTestId("harness-snapshot").fill(""); await expect(page.getByTestId("harness-create")).toBeDisabled(); }); - await test.step("6. pinned by digest and told where snapshots go, it is created", async () => { - await page.getByTestId("harness-snapshot").fill("s3://ate-snapshots/kagent"); - + await test.step("7. with the release image and the defaults, it is created", async () => { + await page.getByTestId("harness-snapshot").fill("s3://ate-snapshots/kagent/"); await expect(page.getByTestId("harness-create")).toBeEnabled(); await page.getByTestId("harness-create").click(); @@ -114,9 +112,24 @@ test("harnesses: a harness is created, read and deleted", async ({ page }) => { // thing exists" are different claims, and only the list checks the second. await page.waitForURL(/tab=harnesses/); await expect(page.getByTestId(table)).toContainText(CREATED, { timeout: 30_000 }); + await expect(rowNamed(page, CREATED)).toContainText("Release default"); + }); + + await test.step("8. it is edited in place, and the change reads back", async () => { + await page.getByTestId(`harness-edit-${CREATED_REF}`).click(); + await page.waitForURL(new RegExp(`/harnesses/kagent/${CREATED}/edit`)); + + await expect(page.getByTestId("harness-name")).toHaveValue(CREATED); + await expect(page.getByTestId("harness-name")).toBeDisabled(); + await page.getByTestId("harness-image").fill(PINNED); + await page.getByTestId("harness-save").click(); + + await page.waitForURL(/tab=harnesses/); + await expect(rowNamed(page, CREATED)).toContainText(PINNED.slice(0, 20), { timeout: 30_000 }); + await expect(rowNamed(page, CREATED)).not.toContainText("Release default"); }); - await test.step("7. a rejected delete reports failure and keeps the row", async () => { + await test.step("9. a rejected delete reports failure and keeps the row", async () => { await page.evaluate(() => { const url = new URL(window.location.href); url.searchParams.set("mock", "error"); @@ -136,7 +149,7 @@ test("harnesses: a harness is created, read and deleted", async ({ page }) => { }); }); - await test.step("8. it is removed from the same tab, and the rest stays", async () => { + await test.step("10. it is removed from the same tab, and the rest stays", async () => { const rows = page.getByTestId(table).locator("tbody tr"); const before = await rows.count(); @@ -150,7 +163,7 @@ test("harnesses: a harness is created, read and deleted", async ({ page }) => { await expect.poll(() => rows.count()).toBe(before - 1); }); - await test.step("9. an empty result leaves the tab standing, with no rows", async () => { + await test.step("11. an empty result leaves the tab standing, with no rows", async () => { // Last, after the delete: reaching these needs the backend answering differently and // `?mock=` is per-navigation, which discards what the journey made. await loadPage(page, routes.harnesses, { scenario: "empty", title: "Agents" }); @@ -159,7 +172,7 @@ test("harnesses: a harness is created, read and deleted", async ({ page }) => { await expect(page.getByTestId("harnesses-error")).toHaveCount(0); }); - await test.step("10. a failed load is reported, not disguised as an empty tab", async () => { + await test.step("12. a failed load is reported, not disguised as an empty tab", async () => { await loadPage(page, routes.harnesses, { scenario: "error", title: "Agents" }); const alert = page.getByTestId("harnesses-error"); diff --git a/ui/playwright/tests/models/models.spec.ts b/ui/playwright/tests/models/models.spec.ts index 520999be23..3eeef183f7 100644 --- a/ui/playwright/tests/models/models.spec.ts +++ b/ui/playwright/tests/models/models.spec.ts @@ -276,13 +276,39 @@ test("models: a configuration is created, read, changed and deleted", async ({ await expect(rowNamed(page, "default-model-config")).toHaveCount(1); }); - await test.step("11. an empty result says so instead of showing a bare table", async () => { + await test.step("11. OpenAI's enum parameters are chosen from their values, and saved", async () => { + const name = "responses-model"; + await loadPage(page, routes.modelNew, { title: "New model" }); + await selectOption(page, "model-provider", "OpenAI"); + await page.getByTestId("model-model").locator("input").fill("gpt-5"); + await page.getByTestId("model-name").fill(name); + await selectOption(page, "model-namespace", "kagent"); + await page.getByTestId("model-api-key").fill("sk-not-a-real-key"); + + await page.getByTestId("model-optional-params").getByText(/Optional parameters/).click(); + // Selects, because the CRD admits only these values and a typo would be refused. + await expect(page.getByText("Codex requires responses.", { exact: false })).toBeVisible(); + await selectOption(page, "model-param-apiFormat", "responses"); + await selectOption(page, "model-param-reasoningEffort", "low"); + + await page.getByTestId("model-submit").click(); + await page.waitForURL(/\/models(\?|$)/, { timeout: 30_000 }); + await page.getByTestId(`edit-${name}`).click(); + await page.waitForURL(new RegExp(`/models/kagent/${name}/edit$`)); + await expectSettled(page); + + await page.getByTestId("model-optional-params").getByText(/Optional parameters/).click(); + await expect(page.getByTestId("model-param-apiFormat")).toContainText("responses"); + await expect(page.getByTestId("model-param-reasoningEffort")).toContainText("low"); + }); + + await test.step("12. an empty result says so instead of showing a bare table", async () => { await loadPage(page, routes.models, { scenario: "empty", title: "Models" }); await expect(page.getByText("No model configurations yet.")).toBeVisible(); await expect(dataRows(page)).toHaveCount(0); }); - await test.step("12. a failed load is reported, not disguised as an empty list", async () => { + await test.step("13. a failed load is reported, not disguised as an empty list", async () => { await loadPage(page, routes.models, { scenario: "error", title: "Models" }); const alert = page.getByTestId("models-error"); @@ -301,7 +327,7 @@ test("models: a configuration is created, read, changed and deleted", async ({ await expect(dataRows(page)).toHaveCount(0); }); - await test.step("13. retrying asks the backend again, and it recovers", async () => { + await test.step("14. retrying asks the backend again, and it recovers", async () => { const before = await operationCalls(page, rpc.listModelConfigs); await page.getByRole("button", { name: "Try again" }).click(); diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index f74108dda6..b30625483e 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -38,6 +38,7 @@ import type { SubstrateActorPage, SubstrateSummary, SubstrateWorkerPage, + SubstrateWorkerPoolEntry, } from "./domain/substrate"; import type { Harness, HarnessResource } from "./domain/harnesses"; import type { @@ -111,6 +112,11 @@ export interface NamespacesApi { export interface SubstrateApi { /** Counts and the two small lists. The only honest source of a total. */ summary(scope?: SubstrateScopeInput, options?: ReadOptions): Promise; + /** + * The worker pools a harness may name, in one namespace or every watched one. + * Reads Kubernetes only, so it works for a caller who may not see the inventory. + */ + workerPools(namespace?: string, options?: ReadOptions): Promise; /** One page of actors, ordered and narrowed server-side across the whole inventory. */ actors( input: SubstrateActorPageInput, @@ -137,18 +143,18 @@ export interface AgentBuildingBlocksApi { * Served by `HarnessService`; a Harness is the reusable runtime half of an agent. */ harnesses(namespace?: string, options?: ReadOptions): Promise; - /** - * Creates a harness. - * - * `HarnessService` implements create, update and delete. A note in this codebase said - * it was read-only, which described what this client exposed rather than what the - * service does — the RPCs were there and implemented the whole time. - */ + /** Creates a harness. `HarnessService` serves list, create, update and delete. */ createHarness(input: { namespace: string; name: string; resource: HarnessResource; }): Promise; + /** Replaces a harness's spec and labels; its name and namespace are fixed. */ + updateHarness(input: { + namespace: string; + name: string; + resource: HarnessResource; + }): Promise; removeHarness(namespace: string, name: string): Promise; /** Every `AgentTemplate` — the behaviour half — in one namespace, or in all of them. */ @@ -328,6 +334,8 @@ export function createApiClient(): KagentApiClient { substrate: { summary: (scope = {}, options) => invoke("substrate.summary", scope, options), + workerPools: (namespace, options) => + invoke("substrate.workerPools", { namespace }, options), // Not sorted here, unlike every other list: the server orders these pages across // the whole inventory, and re-sorting a page would order it within itself while // leaving it in the wrong place in the whole. @@ -344,6 +352,7 @@ export function createApiClient(): KagentApiClient { harnesses: (namespace, options) => invoke("harnesses.list", { namespace }, options).then(sortedByRef), createHarness: (input) => invoke("harnesses.create", input), + updateHarness: (input) => invoke("harnesses.update", input), removeHarness: (namespace, name) => invoke("harnesses.delete", { namespace, name }), agentTemplates: (namespace, options) => invoke("agentTemplates.list", { namespace }, options).then(sortedByRef), diff --git a/ui/src/api/domain/agents.test.ts b/ui/src/api/domain/agents.test.ts index 8e9c9fdb97..03a60f4f6e 100644 --- a/ui/src/api/domain/agents.test.ts +++ b/ui/src/api/domain/agents.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { AgentTemplate } from "./agentTemplates"; -import { agentDescription, agentRevisionState, newConversationBlockedReason, type Agent, type AgentStatus } from "./agents"; +import { agentDescription, agentRevisionState, agentUpdateFailedReason, newConversationBlockedReason, type Agent, type AgentStatus } from "./agents"; function agent(status?: AgentStatus): Agent { return { name: "a", namespace: "team", ref: "team/a", resource: { metadata: { name: "a", namespace: "team" }, spec: { template: { ref: { name: "shared" } }, harnessRef: { name: "runner" } }, status } }; @@ -12,6 +12,24 @@ describe("Agent readiness", () => { expect(agentRevisionState(row)).toBe("updateFailed"); expect(newConversationBlockedReason(row)).toBeUndefined(); }); + it("explains a failed edit with the controller's message while the last good revision runs", () => { + const message = "golden build failed (GoldenActorCrashed): could not pull image ghcr.io/acme/harnes:1.0, retry 1 at 2026-11-07T10:00:15Z"; + const row = agent({ latestSuccessfulRevision: "good", desiredRevision: "new", conditions: [ + { type: "Compatible", status: "True", reason: "Compatible" }, + { type: "Ready", status: "False", reason: "ActorTemplateRetrying", message }, + ] }); + expect(agentUpdateFailedReason(row)).toBe(message); + }); + it("falls back to the failing reason, then a generic sentence, when a failed edit has no message", () => { + expect(agentUpdateFailedReason(agent({ latestSuccessfulRevision: "good", desiredRevision: "new", conditions: [{ type: "Ready", status: "False", reason: "ActorTemplateRetrying" }] }))).toBe("ActorTemplateRetrying"); + expect(agentUpdateFailedReason(agent({ latestSuccessfulRevision: "good", desiredRevision: "new", conditions: [{ type: "Ready", status: "False" }] }))).toBe("The latest change failed."); + }); + it("has no update failure to explain when the agent is ready, updating or never succeeded", () => { + expect(agentUpdateFailedReason(agent({ latestSuccessfulRevision: "good", desiredRevision: "good" }))).toBeUndefined(); + expect(agentUpdateFailedReason(agent({ latestSuccessfulRevision: "good", desiredRevision: "new", conditions: [{ type: "Ready", status: "False", reason: "ActorTemplatePending" }] }))).toBeUndefined(); + // A never-ready agent is explained by `newConversationBlockedReason` instead. + expect(agentUpdateFailedReason(agent({ desiredRevision: "new", conditions: [{ type: "Ready", status: "False", reason: "ActorTemplateRetrying", message: "golden build failed (GoldenActorCrashed)" }] }))).toBeUndefined(); + }); it("reports a preparation failure before any successful revision", () => { const row = agent({ desiredRevision: "new", conditions: [{ type: "Compatible", status: "False", message: "Unsupported output schema" }] }); expect(agentRevisionState(row)).toBe("failed"); diff --git a/ui/src/api/domain/agents.ts b/ui/src/api/domain/agents.ts index bab93a11e8..e5604b535a 100644 --- a/ui/src/api/domain/agents.ts +++ b/ui/src/api/domain/agents.ts @@ -80,6 +80,16 @@ export function agentNotReadyReason({ resource: { status } }: Agent): string | u return failing?.message ?? failing?.reason; } +/** + * Why the latest change to a runnable agent failed, in the controller's words. Nothing is + * blocked, since conversations keep the last good revision, so this is the only place the + * failure shows up. + */ +export function agentUpdateFailedReason(agent: Agent): string | undefined { + if (agentRevisionState(agent) !== "updateFailed") return undefined; + return agentNotReadyReason(agent) ?? "The latest change failed."; +} + export function newConversationBlockedReason(agent: Agent): string | undefined { if (isAgentRunnable(agent)) return undefined; const state = agentRevisionState(agent); diff --git a/ui/src/api/domain/harnesses.ts b/ui/src/api/domain/harnesses.ts index c1cfe6a60c..3801d97091 100644 --- a/ui/src/api/domain/harnesses.ts +++ b/ui/src/api/domain/harnesses.ts @@ -21,7 +21,10 @@ export interface Harness { */ runtime: string; - /** `spec.workload.image`. Digest-pinned — a tag is rejected by CEL on the CRD. */ + /** + * `spec.workload.image`. Digest-pinned — a tag is rejected by the CRD. Empty when + * the harness runs this release's built-in image for its adapter. + */ workloadImage: string; /** @@ -82,7 +85,8 @@ export interface HarnessSpec { claude?: Record; /** Bring your own: an image that serves kagent's A2A contract itself. */ byo?: Record; - workload: { image: string; command?: string[]; args?: string[] }; + /** Optional for the built-in adapters, which default to this release's image; BYO needs an image and a command. */ + workload?: { image?: string; command?: string[]; args?: string[] }; substrate: { workerPoolRef: { name: string }; snapshotPolicy: { location: string }; @@ -96,3 +100,7 @@ export type HarnessAdapter = (typeof HARNESS_ADAPTERS)[number]; /** The digest pin `workload.image` must satisfy, from the CRD's own pattern. */ export const HARNESS_IMAGE_PATTERN = /^[^\s@]+@sha256:[a-f0-9]{64}$/; + +/** The worker pool and snapshot location the chart's default harnesses use. */ +export const DEFAULT_HARNESS_WORKER_POOL = "kagent-default"; +export const DEFAULT_HARNESS_SNAPSHOT_LOCATION = "s3://ate-snapshots/kagent/"; diff --git a/ui/src/api/domain/models.ts b/ui/src/api/domain/models.ts index 8fdc7ec0b7..4e74e27757 100644 --- a/ui/src/api/domain/models.ts +++ b/ui/src/api/domain/models.ts @@ -13,9 +13,16 @@ export interface OpenAIConfig { seed?: number; n?: number; timeout?: number; - reasoningEffort?: string; + reasoningEffort?: OpenAIReasoningEffort; + /** Defaults to chatCompletions on the cluster. Codex needs responses. */ + apiFormat?: OpenAIAPIFormat; } +/** `OpenAIAPIFormat` in the CRD. */ +export type OpenAIAPIFormat = "chatCompletions" | "responses"; +/** `OpenAIReasoningEffort` in the CRD. */ +export type OpenAIReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; + export interface AnthropicConfig { baseUrl?: string; maxTokens?: number; diff --git a/ui/src/api/grpc/operations.ts b/ui/src/api/grpc/operations.ts index 2ae8b2d8bc..f7e7916b05 100644 --- a/ui/src/api/grpc/operations.ts +++ b/ui/src/api/grpc/operations.ts @@ -1076,6 +1076,7 @@ const agentBuildingBlocks: Pick< | "agentTemplates.list" | "agentTemplates.get" | "harnesses.create" + | "harnesses.update" | "harnesses.delete" | "agentTemplates.create" | "agentTemplates.update" @@ -1105,6 +1106,20 @@ const agentBuildingBlocks: Pick< return toHarness(required(response.harness, name, "created harness")); }, + "harnesses.update": async (input, options) => { + const name = "HarnessService/UpdateHarness"; + const response = await rpc(name, options.signal, () => + serviceClient(HarnessService).updateHarness( + { + ref: { namespace: input.namespace, name: input.name }, + resource: wrap("Harness", input.resource), + }, + call("harnesses.update", options), + ), + ); + return toHarness(required(response.harness, name, "updated harness")); + }, + "harnesses.delete": async (input, options) => { await rpc("HarnessService/DeleteHarness", options.signal, () => serviceClient(HarnessService).deleteHarness( @@ -1402,9 +1417,20 @@ const cluster: Pick< ApiOperations, | "namespaces.list" | "substrate.summary" + | "substrate.workerPools" | "substrate.actors" | "substrate.workers" > = { + "substrate.workerPools": async (input, options) => { + const response = await rpc("SystemService/ListWorkerPools", options.signal, () => + serviceClient(SystemService).listWorkerPools( + { namespace: input.namespace ?? "" }, + call("substrate.workerPools", options), + ), + ); + return list(response.workerPools).map(toWorkerPoolEntry); + }, + "namespaces.list": async (_input, options) => { const response = await rpc("SystemService/ListNamespaces", options.signal, () => serviceClient(SystemService).listNamespaces({}, call("namespaces.list", options)), diff --git a/ui/src/api/hooks/useSubstrate.ts b/ui/src/api/hooks/useSubstrate.ts index 6672280d44..f049d35fdb 100644 --- a/ui/src/api/hooks/useSubstrate.ts +++ b/ui/src/api/hooks/useSubstrate.ts @@ -4,6 +4,7 @@ import type { SubstrateActorPage, SubstrateSummary, SubstrateWorkerPage, + SubstrateWorkerPoolEntry, } from "../domain/substrate"; import type { SubstrateActorPageInput, @@ -22,6 +23,16 @@ export function useSubstrateSummary(scope: SubstrateScopeInput = {}): ApiResourc ); } +/** + * The worker pools a harness in `namespace` may name. A form that offers them still + * accepts a typed name, so a caller who may not list pools is not blocked. + */ +export function useWorkerPools(namespace?: string): ApiResource { + return useApiResource(namespace ? ["substrate.workerPools", namespace] : null, () => + apiClient.substrate.workerPools(namespace), + ); +} + /** One page in Substrate's native order. */ export function useSubstrateActors(input: SubstrateActorPageInput): ApiResource { const { atespace = "", limit = 0, pageToken = "" } = input; diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index a160c4a308..7e909617e1 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -96,6 +96,7 @@ export { useSubstrateActors, useSubstrateSummary, useSubstrateWorkers, + useWorkerPools, } from "./hooks/useSubstrate"; export { useAgentTemplate, diff --git a/ui/src/api/operations.ts b/ui/src/api/operations.ts index 77c8238674..ee98421d74 100644 --- a/ui/src/api/operations.ts +++ b/ui/src/api/operations.ts @@ -57,6 +57,7 @@ import type { SubstrateActorPage, SubstrateSummary, SubstrateWorkerPage, + SubstrateWorkerPoolEntry, } from "./domain/substrate"; import type { AgentInstance, @@ -292,14 +293,16 @@ export interface OperationMap { "harnesses.list": { input: { namespace?: string }; output: Harness[] }; + /** Creates a harness from a whole custom resource. */ + "harnesses.create": { + input: { namespace: string; name: string; resource: HarnessResource }; + output: Harness; + }; /** - * Creates a harness from a whole custom resource. - * - * `HarnessService` implements create, update and delete — a note in this codebase - * said it was read-only, and that was wrong: it described what this client exposed - * rather than what the service does. + * Replaces a harness's spec and labels. `metadata.resourceVersion`, when sent, makes + * the write fail on a concurrent change instead of overwriting it. */ - "harnesses.create": { + "harnesses.update": { input: { namespace: string; name: string; resource: HarnessResource }; output: Harness; }; @@ -353,6 +356,11 @@ export interface OperationMap { input: SubstrateScopeInput; output: SubstrateSummary; }; + /** Worker pools a harness may name; Kubernetes only, sorted by namespace then name. */ + "substrate.workerPools": { + input: { namespace?: string }; + output: SubstrateWorkerPoolEntry[]; + }; /** * One page of actors, ordered and narrowed across the whole inventory. * diff --git a/ui/src/components/harness-form/HarnessFields.tsx b/ui/src/components/harness-form/HarnessFields.tsx index 57eaacf6d3..14577494b8 100644 --- a/ui/src/components/harness-form/HarnessFields.tsx +++ b/ui/src/components/harness-form/HarnessFields.tsx @@ -1,24 +1,64 @@ -import { Form, Input, Select } from "antd"; +import { useEffect, useRef } from "react"; +import { AutoComplete, Form, Input, Select, Typography } from "antd"; +import { useTheme } from "@emotion/react"; +import { useWorkerPools } from "@/api"; import { + DEFAULT_HARNESS_WORKER_POOL, HARNESS_ADAPTERS, HARNESS_IMAGE_PATTERN, type HarnessAdapter, } from "@/api/domain/harnesses"; import type { HarnessDraft } from "./harnessDraft"; -/** Fields for a platform-managed Harness resource. */ +/** What a Harness and a WorkerPool are, for the pages and dialogs that create one. */ +export function HarnessIntro() { + const theme = useTheme(); + return ( + + A harness is the runtime an agent runs on: which adapter compiles its template, which + image runs it, and where Substrate schedules it. Agents reference a harness by name, so + many agents can share one. A worker pool is a set of Substrate machines that runs those + agents. + + ); +} + +/** + * Fields for a platform-managed Harness resource. + * + * `namespace` is where the harness lives, which is where its worker pool must be. The + * pools offered are the ones that namespace has; a typed name is accepted too, so a + * caller who may not list pools is not blocked. + */ export function HarnessFields({ draft, onChange, + namespace, }: { draft: HarnessDraft; onChange: (next: HarnessDraft) => void; + namespace?: string; }) { const set = (field: K, value: HarnessDraft[K]) => onChange({ ...draft, [field]: value }); const byo = draft.adapter === "byo"; const image = draft.image.trim(); const imageInvalid = image !== "" && !HARNESS_IMAGE_PATTERN.test(image); + const pools = useWorkerPools(namespace); + const poolNames = (pools.data ?? []).map((pool) => pool.name); + const hasDefaultPool = poolNames.includes(DEFAULT_HARNESS_WORKER_POOL); + + // The chart installs `kagent-default`; preselect it once, and only where it exists, so a + // field the user clears stays clear. + const preselected = useRef(false); + useEffect(() => { + if (preselected.current || !hasDefaultPool) return; + preselected.current = true; + if (draft.workerPool === "") onChange({ ...draft, workerPool: DEFAULT_HARNESS_WORKER_POOL }); + }, [draft, hasDefaultPool, onChange]); return ( <> @@ -37,19 +77,21 @@ export function HarnessFields({ set("image", event.target.value)} - placeholder="ghcr.io/example/runtime@sha256:…" + placeholder={byo ? "ghcr.io/example/runtime@sha256:…" : "Release default"} /> @@ -88,22 +130,35 @@ export function HarnessFields({ - set("workerPool", event.target.value)} - placeholder="kagent-default" + onChange={(value: string) => set("workerPool", value)} + options={poolNames.map((value) => ({ value, label: value }))} + filterOption={(input, option) => Boolean(option?.value.toLowerCase().includes(input.toLowerCase()))} + placeholder={DEFAULT_HARNESS_WORKER_POOL} + notFoundContent={ + namespace && !pools.isLoading && !pools.error ? "No worker pools in this namespace" : undefined + } /> - + set("snapshotLocation", event.target.value)} - placeholder="gs://snapshots/kagent/" + placeholder="s3://ate-snapshots/kagent/" /> diff --git a/ui/src/components/harness-form/HarnessForm.tsx b/ui/src/components/harness-form/HarnessForm.tsx new file mode 100644 index 0000000000..2042f7ff32 --- /dev/null +++ b/ui/src/components/harness-form/HarnessForm.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import { Alert, Button, Form, Input, Select, Space } from "antd"; +import { useTheme } from "@emotion/react"; +import { apiClient, useInvalidateHarnesses, useNamespaces, type Harness, type HarnessSpec } from "@/api"; +import { HarnessFields } from "./HarnessFields"; +import { + emptyHarnessDraft, + harnessDraftFromSpec, + harnessDraftProblems, + harnessSpecFromDraft, +} from "./harnessDraft"; + +/** + * Creates a harness, or edits `harness`. The caller decides where to go afterwards, so + * the same form serves the harness pages and the agent form's inline dialog. + * + * `namespace` fixes the namespace on a create, as the agent form does: an agent can + * only reference a harness in its own namespace. + */ +export function HarnessForm({ + harness, + namespace: fixedNamespace, + onSaved, + onCancel, +}: { + harness?: Harness; + namespace?: string; + onSaved: (saved: Harness) => void; + onCancel: () => void; +}) { + const theme = useTheme(); + const namespaces = useNamespaces(); + const invalidateHarnesses = useInvalidateHarnesses(); + const existing = harness?.resource.spec as HarnessSpec | undefined; + + const [chosenNamespace, setNamespace] = useState(harness?.namespace ?? fixedNamespace); + const namespace = harness?.namespace ?? fixedNamespace ?? chosenNamespace; + const [name, setName] = useState(harness?.name ?? ""); + const [draft, setDraft] = useState(() => (existing ? harnessDraftFromSpec(existing) : emptyHarnessDraft())); + const [saving, setSaving] = useState(false); + const [failure, setFailure] = useState(); + + const ready = Boolean(namespace) && name.trim() !== "" && harnessDraftProblems(draft).length === 0; + + async function save() { + if (!namespace || !ready) return; + setSaving(true); + setFailure(undefined); + const input = { + namespace, + name: name.trim(), + resource: { + // An edit sends the loaded metadata back, so a stale resourceVersion fails the write. + metadata: { ...harness?.resource.metadata, name: name.trim(), namespace }, + spec: harnessSpecFromDraft(draft, existing), + }, + }; + try { + const saved = harness + ? await apiClient.agentBuildingBlocks.updateHarness(input) + : await apiClient.agentBuildingBlocks.createHarness(input); + // Refreshes any list still on screen. Guarded, not load-bearing. + await invalidateHarnesses().catch(() => {}); + onSaved(saved); + } catch (cause: unknown) { + // The controller's own words: its CEL messages name the field that was wrong, + // which is more use than anything this form could say about it. + setFailure(cause instanceof Error ? cause.message : String(cause)); + } finally { + setSaving(false); + } + } + + return ( +
+ + setName(event.target.value)} + placeholder="my-harness" + /> + + + + setParam(key, event.target.value)} - /> + {ENUM_PARAMS[key] ? ( + setParam(key, event.target.value)} + /> + )} ); }; diff --git a/ui/src/generated/kagent/api/v1/harnesses_pb.ts b/ui/src/generated/kagent/api/v1/harnesses_pb.ts index 1fe094a056..3494bd005a 100644 --- a/ui/src/generated/kagent/api/v1/harnesses_pb.ts +++ b/ui/src/generated/kagent/api/v1/harnesses_pb.ts @@ -4,6 +4,7 @@ import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../../buf/validate/validate_pb"; import type { ResourceReference, StructuredObject } from "./common_pb"; import { file_kagent_api_v1_common } from "./common_pb"; import type { Message } from "@bufbuild/protobuf"; @@ -12,7 +13,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file kagent/api/v1/harnesses.proto. */ export const file_kagent_api_v1_harnesses: GenFile = /*@__PURE__*/ - fileDesc("Ch1rYWdlbnQvYXBpL3YxL2hhcm5lc3Nlcy5wcm90bxINa2FnZW50LmFwaS52MSKUAQoHSGFybmVzcxItCgNyZWYYASABKAsyIC5rYWdlbnQuYXBpLnYxLlJlc291cmNlUmVmZXJlbmNlEjEKCHJlc291cmNlGAIgASgLMh8ua2FnZW50LmFwaS52MS5TdHJ1Y3R1cmVkT2JqZWN0Eg8KB3J1bnRpbWUYAyABKAkSFgoOd29ya2xvYWRfaW1hZ2UYBCABKAkiKQoUTGlzdEhhcm5lc3Nlc1JlcXVlc3QSEQoJbmFtZXNwYWNlGAEgASgJIkIKFUxpc3RIYXJuZXNzZXNSZXNwb25zZRIpCgloYXJuZXNzZXMYASADKAsyFi5rYWdlbnQuYXBpLnYxLkhhcm5lc3MieAoUQ3JlYXRlSGFybmVzc1JlcXVlc3QSLQoDcmVmGAEgASgLMiAua2FnZW50LmFwaS52MS5SZXNvdXJjZVJlZmVyZW5jZRIxCghyZXNvdXJjZRgCIAEoCzIfLmthZ2VudC5hcGkudjEuU3RydWN0dXJlZE9iamVjdCJAChVDcmVhdGVIYXJuZXNzUmVzcG9uc2USJwoHaGFybmVzcxgBIAEoCzIWLmthZ2VudC5hcGkudjEuSGFybmVzcyJFChREZWxldGVIYXJuZXNzUmVxdWVzdBItCgNyZWYYASABKAsyIC5rYWdlbnQuYXBpLnYxLlJlc291cmNlUmVmZXJlbmNlIhcKFURlbGV0ZUhhcm5lc3NSZXNwb25zZTKkAgoOSGFybmVzc1NlcnZpY2USWgoNTGlzdEhhcm5lc3NlcxIjLmthZ2VudC5hcGkudjEuTGlzdEhhcm5lc3Nlc1JlcXVlc3QaJC5rYWdlbnQuYXBpLnYxLkxpc3RIYXJuZXNzZXNSZXNwb25zZRJaCg1DcmVhdGVIYXJuZXNzEiMua2FnZW50LmFwaS52MS5DcmVhdGVIYXJuZXNzUmVxdWVzdBokLmthZ2VudC5hcGkudjEuQ3JlYXRlSGFybmVzc1Jlc3BvbnNlEloKDURlbGV0ZUhhcm5lc3MSIy5rYWdlbnQuYXBpLnYxLkRlbGV0ZUhhcm5lc3NSZXF1ZXN0GiQua2FnZW50LmFwaS52MS5EZWxldGVIYXJuZXNzUmVzcG9uc2VCPVo7Z2l0aHViLmNvbS9rYWdlbnQtZGV2L2thZ2VudC9nby9hcGkvZ2VuL2thZ2VudC9hcGkvdjE7YXBpdjFiBnByb3RvMw", [file_kagent_api_v1_common]); + fileDesc("Ch1rYWdlbnQvYXBpL3YxL2hhcm5lc3Nlcy5wcm90bxINa2FnZW50LmFwaS52MSKUAQoHSGFybmVzcxItCgNyZWYYASABKAsyIC5rYWdlbnQuYXBpLnYxLlJlc291cmNlUmVmZXJlbmNlEjEKCHJlc291cmNlGAIgASgLMh8ua2FnZW50LmFwaS52MS5TdHJ1Y3R1cmVkT2JqZWN0Eg8KB3J1bnRpbWUYAyABKAkSFgoOd29ya2xvYWRfaW1hZ2UYBCABKAkiKQoUTGlzdEhhcm5lc3Nlc1JlcXVlc3QSEQoJbmFtZXNwYWNlGAEgASgJIkIKFUxpc3RIYXJuZXNzZXNSZXNwb25zZRIpCgloYXJuZXNzZXMYASADKAsyFi5rYWdlbnQuYXBpLnYxLkhhcm5lc3MieAoUQ3JlYXRlSGFybmVzc1JlcXVlc3QSLQoDcmVmGAEgASgLMiAua2FnZW50LmFwaS52MS5SZXNvdXJjZVJlZmVyZW5jZRIxCghyZXNvdXJjZRgCIAEoCzIfLmthZ2VudC5hcGkudjEuU3RydWN0dXJlZE9iamVjdCJAChVDcmVhdGVIYXJuZXNzUmVzcG9uc2USJwoHaGFybmVzcxgBIAEoCzIWLmthZ2VudC5hcGkudjEuSGFybmVzcyKIAQoUVXBkYXRlSGFybmVzc1JlcXVlc3QSNQoDcmVmGAEgASgLMiAua2FnZW50LmFwaS52MS5SZXNvdXJjZVJlZmVyZW5jZUIGukgDyAEBEjkKCHJlc291cmNlGAIgASgLMh8ua2FnZW50LmFwaS52MS5TdHJ1Y3R1cmVkT2JqZWN0Qga6SAPIAQEiQAoVVXBkYXRlSGFybmVzc1Jlc3BvbnNlEicKB2hhcm5lc3MYASABKAsyFi5rYWdlbnQuYXBpLnYxLkhhcm5lc3MiRQoURGVsZXRlSGFybmVzc1JlcXVlc3QSLQoDcmVmGAEgASgLMiAua2FnZW50LmFwaS52MS5SZXNvdXJjZVJlZmVyZW5jZSIXChVEZWxldGVIYXJuZXNzUmVzcG9uc2UygAMKDkhhcm5lc3NTZXJ2aWNlEloKDUxpc3RIYXJuZXNzZXMSIy5rYWdlbnQuYXBpLnYxLkxpc3RIYXJuZXNzZXNSZXF1ZXN0GiQua2FnZW50LmFwaS52MS5MaXN0SGFybmVzc2VzUmVzcG9uc2USWgoNQ3JlYXRlSGFybmVzcxIjLmthZ2VudC5hcGkudjEuQ3JlYXRlSGFybmVzc1JlcXVlc3QaJC5rYWdlbnQuYXBpLnYxLkNyZWF0ZUhhcm5lc3NSZXNwb25zZRJaCg1VcGRhdGVIYXJuZXNzEiMua2FnZW50LmFwaS52MS5VcGRhdGVIYXJuZXNzUmVxdWVzdBokLmthZ2VudC5hcGkudjEuVXBkYXRlSGFybmVzc1Jlc3BvbnNlEloKDURlbGV0ZUhhcm5lc3MSIy5rYWdlbnQuYXBpLnYxLkRlbGV0ZUhhcm5lc3NSZXF1ZXN0GiQua2FnZW50LmFwaS52MS5EZWxldGVIYXJuZXNzUmVzcG9uc2VCPVo7Z2l0aHViLmNvbS9rYWdlbnQtZGV2L2thZ2VudC9nby9hcGkvZ2VuL2thZ2VudC9hcGkvdjE7YXBpdjFiBnByb3RvMw", [file_buf_validate_validate, file_kagent_api_v1_common]); /** * @generated from message kagent.api.v1.Harness @@ -41,7 +42,8 @@ export type Harness = Message<"kagent.api.v1.Harness"> & { runtime: string; /** - * WorkloadImage is spec.workload.image, the digest-pinned runtime image. + * WorkloadImage is spec.workload.image, the digest-pinned runtime image. Empty + * when the spec leaves it to the controller's release default. * * @generated from field: string workload_image = 4; */ @@ -128,6 +130,45 @@ export type CreateHarnessResponse = Message<"kagent.api.v1.CreateHarnessResponse export const CreateHarnessResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_kagent_api_v1_harnesses, 4); +/** + * @generated from message kagent.api.v1.UpdateHarnessRequest + */ +export type UpdateHarnessRequest = Message<"kagent.api.v1.UpdateHarnessRequest"> & { + /** + * @generated from field: kagent.api.v1.ResourceReference ref = 1; + */ + ref?: ResourceReference | undefined; + + /** + * @generated from field: kagent.api.v1.StructuredObject resource = 2; + */ + resource?: StructuredObject | undefined; +}; + +/** + * Describes the message kagent.api.v1.UpdateHarnessRequest. + * Use `create(UpdateHarnessRequestSchema)` to create a new message. + */ +export const UpdateHarnessRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_kagent_api_v1_harnesses, 5); + +/** + * @generated from message kagent.api.v1.UpdateHarnessResponse + */ +export type UpdateHarnessResponse = Message<"kagent.api.v1.UpdateHarnessResponse"> & { + /** + * @generated from field: kagent.api.v1.Harness harness = 1; + */ + harness?: Harness | undefined; +}; + +/** + * Describes the message kagent.api.v1.UpdateHarnessResponse. + * Use `create(UpdateHarnessResponseSchema)` to create a new message. + */ +export const UpdateHarnessResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_kagent_api_v1_harnesses, 6); + /** * @generated from message kagent.api.v1.DeleteHarnessRequest */ @@ -143,7 +184,7 @@ export type DeleteHarnessRequest = Message<"kagent.api.v1.DeleteHarnessRequest"> * Use `create(DeleteHarnessRequestSchema)` to create a new message. */ export const DeleteHarnessRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_harnesses, 5); + messageDesc(file_kagent_api_v1_harnesses, 7); /** * @generated from message kagent.api.v1.DeleteHarnessResponse @@ -156,7 +197,7 @@ export type DeleteHarnessResponse = Message<"kagent.api.v1.DeleteHarnessResponse * Use `create(DeleteHarnessResponseSchema)` to create a new message. */ export const DeleteHarnessResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1_harnesses, 6); + messageDesc(file_kagent_api_v1_harnesses, 8); /** * HarnessService manages reusable runtime and execution configuration. @@ -182,6 +223,17 @@ export const HarnessService: GenService<{ input: typeof CreateHarnessRequestSchema; output: typeof CreateHarnessResponseSchema; }, + /** + * UpdateHarness replaces the spec and labels of an existing Harness. Agents + * that reference it pick up the change on their next reconcile. + * + * @generated from rpc kagent.api.v1.HarnessService.UpdateHarness + */ + updateHarness: { + methodKind: "unary"; + input: typeof UpdateHarnessRequestSchema; + output: typeof UpdateHarnessResponseSchema; + }, /** * @generated from rpc kagent.api.v1.HarnessService.DeleteHarness */ diff --git a/ui/src/generated/kagent/api/v1/system_pb.ts b/ui/src/generated/kagent/api/v1/system_pb.ts index 7d8c7f4605..ca57e6eded 100644 --- a/ui/src/generated/kagent/api/v1/system_pb.ts +++ b/ui/src/generated/kagent/api/v1/system_pb.ts @@ -17,7 +17,7 @@ import type { JsonObject, Message } from "@bufbuild/protobuf"; * Describes the file kagent/api/v1/system.proto. */ export const file_kagent_api_v1_system: GenFile = /*@__PURE__*/ - fileDesc("ChprYWdlbnQvYXBpL3YxL3N5c3RlbS5wcm90bxINa2FnZW50LmFwaS52MSITChFHZXRWZXJzaW9uUmVxdWVzdCJUChJHZXRWZXJzaW9uUmVzcG9uc2USFgoOa2FnZW50X3ZlcnNpb24YASABKAkSEgoKZ2l0X2NvbW1pdBgCIAEoCRISCgpidWlsZF9kYXRlGAMgASgJIhcKFUdldEN1cnJlbnRVc2VyUmVxdWVzdCJBChZHZXRDdXJyZW50VXNlclJlc3BvbnNlEicKBmNsYWltcxgBIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3QiFwoVTGlzdE5hbWVzcGFjZXNSZXF1ZXN0IikKCU5hbWVzcGFjZRIMCgRuYW1lGAEgASgJEg4KBnN0YXR1cxgCIAEoCSJGChZMaXN0TmFtZXNwYWNlc1Jlc3BvbnNlEiwKCm5hbWVzcGFjZXMYASADKAsyGC5rYWdlbnQuYXBpLnYxLk5hbWVzcGFjZSJ3ChNTdWJzdHJhdGVXb3JrZXJQb29sEi0KA3JlZhgFIAEoCzIgLmthZ2VudC5hcGkudjEuUmVzb3VyY2VSZWZlcmVuY2USMQoIcmVzb3VyY2UYBiABKAsyHy5rYWdlbnQuYXBpLnYxLlN0cnVjdHVyZWRPYmplY3QimwEKGkdldFN1YnN0cmF0ZVN1bW1hcnlSZXF1ZXN0Ej4KCW5hbWVzcGFjZRgBIAEoCUIrukgociYYPzIiXihbYS16MC05XShbLWEtejAtOV0qW2EtejAtOV0pPyk/JBI9CghhdGVzcGFjZRgCIAEoCUIrukgociYYPzIiXihbYS16MC05XShbLWEtejAtOV0qW2EtejAtOV0pPyk/JCJaChlTdWJzdHJhdGVBY3RvclN0YXR1c0NvdW50Eg0KBWNvdW50GAIgASgDEi4KBXN0YXRlGAMgASgOMh8uc3Vic3RyYXRlLmF0ZWFwaS52MS5BY3RvclN0YXRlIoYDChtHZXRTdWJzdHJhdGVTdW1tYXJ5UmVzcG9uc2USFQoNYXRlX2FwaV9lcnJvchgCIAEoCRI4Cgx3b3JrZXJfcG9vbHMYAyADKAsyIi5rYWdlbnQuYXBpLnYxLlN1YnN0cmF0ZVdvcmtlclBvb2wSEwoLYWN0b3JfY291bnQYBSABKAMSFAoMd29ya2VyX2NvdW50GAYgASgDEhsKE3J1bm5pbmdfYWN0b3JfY291bnQYByABKAMSGQoRYnVzeV93b3JrZXJfY291bnQYCCABKAMSRQoTYWN0b3Jfc3RhdHVzX2NvdW50cxgJIAMoCzIoLmthZ2VudC5hcGkudjEuU3Vic3RyYXRlQWN0b3JTdGF0dXNDb3VudBIvCgtjb21wdXRlZF9hdBgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASOwoPYWN0b3JfdGVtcGxhdGVzGAsgAygLMiIuc3Vic3RyYXRlLmF0ZWFwaS52MS5BY3RvclRlbXBsYXRlIoUBChpMaXN0U3Vic3RyYXRlQWN0b3JzUmVxdWVzdBI9CghhdGVzcGFjZRgGIAEoCUIrukgociYYPzIiXihbYS16MC05XShbLWEtejAtOV0qW2EtejAtOV0pPyk/JBIoCgRwYWdlGAIgASgLMhoua2FnZW50LmFwaS52MS5QYWdlUmVxdWVzdCK8AQobTGlzdFN1YnN0cmF0ZUFjdG9yc1Jlc3BvbnNlEhUKDWF0ZV9hcGlfZXJyb3IYAiABKAkSKgoGYWN0b3JzGAMgAygLMhouc3Vic3RyYXRlLmF0ZWFwaS52MS5BY3RvchIpCgRwYWdlGAQgASgLMhsua2FnZW50LmFwaS52MS5QYWdlUmVzcG9uc2USLwoLY29tcHV0ZWRfYXQYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIocBChtMaXN0U3Vic3RyYXRlV29ya2Vyc1JlcXVlc3QSPgoJbmFtZXNwYWNlGAEgASgJQiu6SChyJhg/MiJeKFthLXowLTldKFstYS16MC05XSpbYS16MC05XSk/KT8kEigKBHBhZ2UYAiABKAsyGi5rYWdlbnQuYXBpLnYxLlBhZ2VSZXF1ZXN0Ir8BChxMaXN0U3Vic3RyYXRlV29ya2Vyc1Jlc3BvbnNlEhUKDWF0ZV9hcGlfZXJyb3IYAiABKAkSLAoHd29ya2VycxgDIAMoCzIbLnN1YnN0cmF0ZS5hdGVhcGkudjEuV29ya2VyEikKBHBhZ2UYBCABKAsyGy5rYWdlbnQuYXBpLnYxLlBhZ2VSZXNwb25zZRIvCgtjb21wdXRlZF9hdBgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAy7QQKDVN5c3RlbVNlcnZpY2USUQoKR2V0VmVyc2lvbhIgLmthZ2VudC5hcGkudjEuR2V0VmVyc2lvblJlcXVlc3QaIS5rYWdlbnQuYXBpLnYxLkdldFZlcnNpb25SZXNwb25zZRJdCg5HZXRDdXJyZW50VXNlchIkLmthZ2VudC5hcGkudjEuR2V0Q3VycmVudFVzZXJSZXF1ZXN0GiUua2FnZW50LmFwaS52MS5HZXRDdXJyZW50VXNlclJlc3BvbnNlEl0KDkxpc3ROYW1lc3BhY2VzEiQua2FnZW50LmFwaS52MS5MaXN0TmFtZXNwYWNlc1JlcXVlc3QaJS5rYWdlbnQuYXBpLnYxLkxpc3ROYW1lc3BhY2VzUmVzcG9uc2USbAoTR2V0U3Vic3RyYXRlU3VtbWFyeRIpLmthZ2VudC5hcGkudjEuR2V0U3Vic3RyYXRlU3VtbWFyeVJlcXVlc3QaKi5rYWdlbnQuYXBpLnYxLkdldFN1YnN0cmF0ZVN1bW1hcnlSZXNwb25zZRJsChNMaXN0U3Vic3RyYXRlQWN0b3JzEikua2FnZW50LmFwaS52MS5MaXN0U3Vic3RyYXRlQWN0b3JzUmVxdWVzdBoqLmthZ2VudC5hcGkudjEuTGlzdFN1YnN0cmF0ZUFjdG9yc1Jlc3BvbnNlEm8KFExpc3RTdWJzdHJhdGVXb3JrZXJzEioua2FnZW50LmFwaS52MS5MaXN0U3Vic3RyYXRlV29ya2Vyc1JlcXVlc3QaKy5rYWdlbnQuYXBpLnYxLkxpc3RTdWJzdHJhdGVXb3JrZXJzUmVzcG9uc2VCPVo7Z2l0aHViLmNvbS9rYWdlbnQtZGV2L2thZ2VudC9nby9hcGkvZ2VuL2thZ2VudC9hcGkvdjE7YXBpdjFiBnByb3RvMw", [file_api_ateapi_v1_ateapi, file_buf_validate_validate, file_google_protobuf_struct, file_google_protobuf_timestamp, file_kagent_api_v1_common]); + fileDesc("ChprYWdlbnQvYXBpL3YxL3N5c3RlbS5wcm90bxINa2FnZW50LmFwaS52MSITChFHZXRWZXJzaW9uUmVxdWVzdCJUChJHZXRWZXJzaW9uUmVzcG9uc2USFgoOa2FnZW50X3ZlcnNpb24YASABKAkSEgoKZ2l0X2NvbW1pdBgCIAEoCRISCgpidWlsZF9kYXRlGAMgASgJIhcKFUdldEN1cnJlbnRVc2VyUmVxdWVzdCJBChZHZXRDdXJyZW50VXNlclJlc3BvbnNlEicKBmNsYWltcxgBIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3QiFwoVTGlzdE5hbWVzcGFjZXNSZXF1ZXN0IikKCU5hbWVzcGFjZRIMCgRuYW1lGAEgASgJEg4KBnN0YXR1cxgCIAEoCSJGChZMaXN0TmFtZXNwYWNlc1Jlc3BvbnNlEiwKCm5hbWVzcGFjZXMYASADKAsyGC5rYWdlbnQuYXBpLnYxLk5hbWVzcGFjZSJ3ChNTdWJzdHJhdGVXb3JrZXJQb29sEi0KA3JlZhgFIAEoCzIgLmthZ2VudC5hcGkudjEuUmVzb3VyY2VSZWZlcmVuY2USMQoIcmVzb3VyY2UYBiABKAsyHy5rYWdlbnQuYXBpLnYxLlN0cnVjdHVyZWRPYmplY3QimwEKGkdldFN1YnN0cmF0ZVN1bW1hcnlSZXF1ZXN0Ej4KCW5hbWVzcGFjZRgBIAEoCUIrukgociYYPzIiXihbYS16MC05XShbLWEtejAtOV0qW2EtejAtOV0pPyk/JBI9CghhdGVzcGFjZRgCIAEoCUIrukgociYYPzIiXihbYS16MC05XShbLWEtejAtOV0qW2EtejAtOV0pPyk/JCJaChlTdWJzdHJhdGVBY3RvclN0YXR1c0NvdW50Eg0KBWNvdW50GAIgASgDEi4KBXN0YXRlGAMgASgOMh8uc3Vic3RyYXRlLmF0ZWFwaS52MS5BY3RvclN0YXRlIoYDChtHZXRTdWJzdHJhdGVTdW1tYXJ5UmVzcG9uc2USFQoNYXRlX2FwaV9lcnJvchgCIAEoCRI4Cgx3b3JrZXJfcG9vbHMYAyADKAsyIi5rYWdlbnQuYXBpLnYxLlN1YnN0cmF0ZVdvcmtlclBvb2wSEwoLYWN0b3JfY291bnQYBSABKAMSFAoMd29ya2VyX2NvdW50GAYgASgDEhsKE3J1bm5pbmdfYWN0b3JfY291bnQYByABKAMSGQoRYnVzeV93b3JrZXJfY291bnQYCCABKAMSRQoTYWN0b3Jfc3RhdHVzX2NvdW50cxgJIAMoCzIoLmthZ2VudC5hcGkudjEuU3Vic3RyYXRlQWN0b3JTdGF0dXNDb3VudBIvCgtjb21wdXRlZF9hdBgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASOwoPYWN0b3JfdGVtcGxhdGVzGAsgAygLMiIuc3Vic3RyYXRlLmF0ZWFwaS52MS5BY3RvclRlbXBsYXRlIoUBChpMaXN0U3Vic3RyYXRlQWN0b3JzUmVxdWVzdBI9CghhdGVzcGFjZRgGIAEoCUIrukgociYYPzIiXihbYS16MC05XShbLWEtejAtOV0qW2EtejAtOV0pPyk/JBIoCgRwYWdlGAIgASgLMhoua2FnZW50LmFwaS52MS5QYWdlUmVxdWVzdCK8AQobTGlzdFN1YnN0cmF0ZUFjdG9yc1Jlc3BvbnNlEhUKDWF0ZV9hcGlfZXJyb3IYAiABKAkSKgoGYWN0b3JzGAMgAygLMhouc3Vic3RyYXRlLmF0ZWFwaS52MS5BY3RvchIpCgRwYWdlGAQgASgLMhsua2FnZW50LmFwaS52MS5QYWdlUmVzcG9uc2USLwoLY29tcHV0ZWRfYXQYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIocBChtMaXN0U3Vic3RyYXRlV29ya2Vyc1JlcXVlc3QSPgoJbmFtZXNwYWNlGAEgASgJQiu6SChyJhg/MiJeKFthLXowLTldKFstYS16MC05XSpbYS16MC05XSk/KT8kEigKBHBhZ2UYAiABKAsyGi5rYWdlbnQuYXBpLnYxLlBhZ2VSZXF1ZXN0Ir8BChxMaXN0U3Vic3RyYXRlV29ya2Vyc1Jlc3BvbnNlEhUKDWF0ZV9hcGlfZXJyb3IYAiABKAkSLAoHd29ya2VycxgDIAMoCzIbLnN1YnN0cmF0ZS5hdGVhcGkudjEuV29ya2VyEikKBHBhZ2UYBCABKAsyGy5rYWdlbnQuYXBpLnYxLlBhZ2VSZXNwb25zZRIvCgtjb21wdXRlZF9hdBgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiWAoWTGlzdFdvcmtlclBvb2xzUmVxdWVzdBI+CgluYW1lc3BhY2UYASABKAlCK7pIKHImGD8yIl4oW2EtejAtOV0oWy1hLXowLTldKlthLXowLTldKT8pPyQiUwoXTGlzdFdvcmtlclBvb2xzUmVzcG9uc2USOAoMd29ya2VyX3Bvb2xzGAEgAygLMiIua2FnZW50LmFwaS52MS5TdWJzdHJhdGVXb3JrZXJQb29sMs8FCg1TeXN0ZW1TZXJ2aWNlElEKCkdldFZlcnNpb24SIC5rYWdlbnQuYXBpLnYxLkdldFZlcnNpb25SZXF1ZXN0GiEua2FnZW50LmFwaS52MS5HZXRWZXJzaW9uUmVzcG9uc2USXQoOR2V0Q3VycmVudFVzZXISJC5rYWdlbnQuYXBpLnYxLkdldEN1cnJlbnRVc2VyUmVxdWVzdBolLmthZ2VudC5hcGkudjEuR2V0Q3VycmVudFVzZXJSZXNwb25zZRJdCg5MaXN0TmFtZXNwYWNlcxIkLmthZ2VudC5hcGkudjEuTGlzdE5hbWVzcGFjZXNSZXF1ZXN0GiUua2FnZW50LmFwaS52MS5MaXN0TmFtZXNwYWNlc1Jlc3BvbnNlEmwKE0dldFN1YnN0cmF0ZVN1bW1hcnkSKS5rYWdlbnQuYXBpLnYxLkdldFN1YnN0cmF0ZVN1bW1hcnlSZXF1ZXN0Gioua2FnZW50LmFwaS52MS5HZXRTdWJzdHJhdGVTdW1tYXJ5UmVzcG9uc2USbAoTTGlzdFN1YnN0cmF0ZUFjdG9ycxIpLmthZ2VudC5hcGkudjEuTGlzdFN1YnN0cmF0ZUFjdG9yc1JlcXVlc3QaKi5rYWdlbnQuYXBpLnYxLkxpc3RTdWJzdHJhdGVBY3RvcnNSZXNwb25zZRJvChRMaXN0U3Vic3RyYXRlV29ya2VycxIqLmthZ2VudC5hcGkudjEuTGlzdFN1YnN0cmF0ZVdvcmtlcnNSZXF1ZXN0Gisua2FnZW50LmFwaS52MS5MaXN0U3Vic3RyYXRlV29ya2Vyc1Jlc3BvbnNlEmAKD0xpc3RXb3JrZXJQb29scxIlLmthZ2VudC5hcGkudjEuTGlzdFdvcmtlclBvb2xzUmVxdWVzdBomLmthZ2VudC5hcGkudjEuTGlzdFdvcmtlclBvb2xzUmVzcG9uc2VCPVo7Z2l0aHViLmNvbS9rYWdlbnQtZGV2L2thZ2VudC9nby9hcGkvZ2VuL2thZ2VudC9hcGkvdjE7YXBpdjFiBnByb3RvMw", [file_api_ateapi_v1_ateapi, file_buf_validate_validate, file_google_protobuf_struct, file_google_protobuf_timestamp, file_kagent_api_v1_common]); /** * @generated from message kagent.api.v1.GetVersionRequest @@ -414,6 +414,44 @@ export type ListSubstrateWorkersResponse = Message<"kagent.api.v1.ListSubstrateW export const ListSubstrateWorkersResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_kagent_api_v1_system, 14); +/** + * @generated from message kagent.api.v1.ListWorkerPoolsRequest + */ +export type ListWorkerPoolsRequest = Message<"kagent.api.v1.ListWorkerPoolsRequest"> & { + /** + * Kubernetes namespace of the pools. Empty uses watched namespaces. + * + * @generated from field: string namespace = 1; + */ + namespace: string; +}; + +/** + * Describes the message kagent.api.v1.ListWorkerPoolsRequest. + * Use `create(ListWorkerPoolsRequestSchema)` to create a new message. + */ +export const ListWorkerPoolsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_kagent_api_v1_system, 15); + +/** + * @generated from message kagent.api.v1.ListWorkerPoolsResponse + */ +export type ListWorkerPoolsResponse = Message<"kagent.api.v1.ListWorkerPoolsResponse"> & { + /** + * Pools the caller may read, in namespace/name order. + * + * @generated from field: repeated kagent.api.v1.SubstrateWorkerPool worker_pools = 1; + */ + workerPools: SubstrateWorkerPool[]; +}; + +/** + * Describes the message kagent.api.v1.ListWorkerPoolsResponse. + * Use `create(ListWorkerPoolsResponseSchema)` to create a new message. + */ +export const ListWorkerPoolsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_kagent_api_v1_system, 16); + /** * @generated from service kagent.api.v1.SystemService */ @@ -466,6 +504,18 @@ export const SystemService: GenService<{ input: typeof ListSubstrateWorkersRequestSchema; output: typeof ListSubstrateWorkersResponseSchema; }, + /** + * ListWorkerPools reads WorkerPool resources from Kubernetes only. A Harness + * names a pool in its own namespace, so forms use this rather than the summary, + * which walks every ate-api actor and worker. + * + * @generated from rpc kagent.api.v1.SystemService.ListWorkerPools + */ + listWorkerPools: { + methodKind: "unary"; + input: typeof ListWorkerPoolsRequestSchema; + output: typeof ListWorkerPoolsResponseSchema; + }, }> = /*@__PURE__*/ serviceDesc(file_kagent_api_v1_system, 0); diff --git a/ui/src/mocks/fixtures.ts b/ui/src/mocks/fixtures.ts index 41c068e0c5..9e494dcda8 100644 --- a/ui/src/mocks/fixtures.ts +++ b/ui/src/mocks/fixtures.ts @@ -121,6 +121,7 @@ export const mockProviders: Provider[] = [ "n", "timeout", "reasoningEffort", + "apiFormat", ], }, { @@ -606,6 +607,24 @@ export const mockAgentInstances: AgentInstance[] = [ * exactly the class of failure this file's fixtures have caused before. */ export const mockHarnesses: Harness[] = [ + // The three the chart installs: no workload, so each runs the release's image. + ...(["kagent", "codex", "claude"] as const).map((runtime) => ({ + ref: `kagent/${runtime}-default`, + namespace: "kagent", + name: `${runtime}-default`, + runtime, + workloadImage: "", + resource: { + metadata: { name: `${runtime}-default`, namespace: "kagent" }, + spec: { + [runtime]: {}, + substrate: { + workerPoolRef: { name: "kagent-default" }, + snapshotPolicy: { location: "s3://ate-snapshots/kagent/" }, + }, + }, + }, + })), { ref: "kagent/k8s-agent", namespace: "kagent", @@ -866,7 +885,22 @@ export const mockAgents: Agent[] = [ harnessRef: { name: "k8s-agent" }, egressPolicyRefs: [{ name: "github-api" }], }, withEgressApplied(readyStatus("rev-release-notes"))), - agent("kagent", "triage-on-claude", { template: { ref: { name: "support-triage-2b91d0e" } }, harnessRef: { name: "support-triage" } }), + // Ran once, then an edit pointed it at a Harness image nobody can pull. The message is + // what `controller/golden_retry.go` writes while it waits to rebuild the golden snapshot. + agent("kagent", "triage-on-claude", { template: { ref: { name: "support-triage-2b91d0e" } }, harnessRef: { name: "support-triage" } }, { + observedGeneration: 2, + desiredRevision: "rev-triage-on-claude-2", + latestSuccessfulRevision: "rev-triage-on-claude", + conditions: [ + { type: "Accepted", status: "True", reason: "Accepted", message: "Agent explicitly selects its template and harness" }, + { type: "ResolvedRefs", status: "True", reason: "Resolved", message: "All runtime references resolved" }, + { type: "Compatible", status: "True", reason: "Compatible", message: "Resolved configuration is compatible with the Harness" }, + { + type: "Ready", status: "False", reason: "ActorTemplateRetrying", + message: "golden build failed (GoldenActorCrashed): could not pull image ghcr.io/kagent-dev/kagent/claude-harnes:0.9.0, retry 2 at 2026-11-07T10:01:12Z", + }, + ], + }), agent("kagent", "scratchpad", { template: { inline: { modelConfig: { name: "default-model-config" }, diff --git a/ui/src/mocks/mockBackend.test.ts b/ui/src/mocks/mockBackend.test.ts index 2d0cefebcf..e6807cf786 100644 --- a/ui/src/mocks/mockBackend.test.ts +++ b/ui/src/mocks/mockBackend.test.ts @@ -147,12 +147,24 @@ const INPUTS = { metadata: { name: "made-up", namespace: "kagent" }, spec: { kagent: {}, - workload: { - image: `ghcr.io/example/runtime@sha256:${"a".repeat(64)}`, + substrate: { + workerPoolRef: { name: "kagent-default" }, + snapshotPolicy: { location: "s3://ate-snapshots/kagent/" }, }, + }, + }, + }, + // A harness nothing else here touches, so the concurrent sweep cannot race it. + "harnesses.update": { + namespace: "analytics", + name: "reporting", + resource: { + metadata: { name: "reporting", namespace: "analytics" }, + spec: { + kagent: {}, substrate: { workerPoolRef: { name: "kagent-default" }, - snapshotPolicy: { location: "gs://snapshots/kagent/" }, + snapshotPolicy: { location: "s3://ate-snapshots/kagent/" }, }, }, }, @@ -249,6 +261,7 @@ const INPUTS = { "namespaces.list": {}, "substrate.summary": {}, + "substrate.workerPools": { namespace: "kagent" }, "substrate.actors": {}, "substrate.workers": {}, } satisfies { [K in OperationId]: OperationInput }; @@ -405,3 +418,48 @@ describe("the fixture backend", () => { }); }); }); + +describe("harness writes follow the CRD", () => { + const substrate = { + workerPoolRef: { name: "kagent-default" }, + snapshotPolicy: { location: "s3://ate-snapshots/kagent/" }, + }; + const write = (name: string, spec: Record, resourceVersion?: string) => ({ + namespace: "kagent", + name, + resource: { metadata: { name, namespace: "kagent", resourceVersion }, spec } as never, + }); + + it("accepts a built-in adapter with no image, as the release default", async () => { + const created = await invoke("harnesses.create", write("rules-default", { codex: {}, substrate })); + expect(created).toMatchObject({ runtime: "codex", workloadImage: "" }); + }); + + it("refuses a bring-your-own harness with no image", async () => { + await expect( + invoke("harnesses.create", write("rules-byo", { byo: {}, workload: { command: ["/app"] }, substrate })), + ).rejects.toMatchObject({ code: "InvalidArgument" }); + }); + + it("refuses a missing snapshot location", async () => { + await expect( + invoke("harnesses.create", write("rules-snapshot", { kagent: {}, substrate: { workerPoolRef: { name: "p" } } })), + ).rejects.toMatchObject({ code: "InvalidArgument" }); + }); + + it("updates the spec and refuses a stale resourceVersion", async () => { + const created = await invoke("harnesses.create", write("rules-update", { kagent: {}, substrate })); + const version = created.resource.metadata.resourceVersion; + const updated = await invoke("harnesses.update", write("rules-update", { claude: {}, substrate }, version)); + expect(updated.runtime).toBe("claude"); + + await expect( + invoke("harnesses.update", write("rules-update", { kagent: {}, substrate }, version)), + ).rejects.toMatchObject({ code: "Aborted" }); + }); + + it("lists the worker pools in one namespace", async () => { + const pools = await invoke("substrate.workerPools", { namespace: "kagent" }); + expect(pools.map((pool) => pool.name)).toEqual(["kagent-default"]); + }); +}); diff --git a/ui/src/mocks/transport.ts b/ui/src/mocks/transport.ts index a944067b96..14a1cc3c91 100644 --- a/ui/src/mocks/transport.ts +++ b/ui/src/mocks/transport.ts @@ -1030,12 +1030,7 @@ on(HarnessService.method.listHarnesses, (input, call) => { return { harnesses: allHarnesses() .filter((harness) => scope === "" || harness.namespace === scope) - .map((harness) => ({ - ref: { namespace: harness.namespace, name: harness.name }, - resource: structured("Harness", harness.resource as unknown as JsonObject), - runtime: harness.runtime, - workloadImage: harness.workloadImage, - })), + .map(harnessMessage), }; }); @@ -1078,7 +1073,7 @@ function harnessFromResource(namespace: string, name: string, resource: JsonObje claude?: unknown; workload?: { image?: string }; }; - const runtime = ["kagent", "codex", "claude"].find( + const runtime = ["kagent", "codex", "claude", "byo"].find( (key) => (spec as Record)[key] !== undefined, ); return { @@ -1139,58 +1134,113 @@ on(AgentTemplateService.method.getAgentTemplate, (input) => ({ ), })); -on(HarnessService.method.createHarness, (input, call) => { - const namespace = requireNamespace(input.ref?.namespace ?? ""); - const name = input.ref?.name ?? ""; - if (name === "") { - throw new ConnectError("Harness namespace and name are required", Code.InvalidArgument); - } - if (call.scenario === "error") { - throw new ConnectError("the mock backend was asked to fail", Code.Internal); - } - const value = (input.resource?.value ?? {}) as JsonObject; +/** + * The CRD's own rules, refused here rather than accepted and forgotten. + * + * A fixture that took anything would let a form ship that builds a resource a + * cluster rejects — which is the failure this backend exists to catch, and the one + * that is invisible until somebody tries it for real. Mirrors + * `go/api/v1alpha3/harness_types.go`: the image is optional except for BYO, which + * also needs a command, and the substrate policy is required. + */ +function admitHarnessSpec(value: JsonObject) { const spec = (value.spec ?? {}) as { - kagent?: unknown; - codex?: unknown; - claude?: unknown; - workload?: { image?: string }; - substrate?: { workerPoolRef?: { name?: string } }; + byo?: unknown; + workload?: { image?: string; command?: string[] }; + substrate?: { workerPoolRef?: { name?: string }; snapshotPolicy?: { location?: string } }; }; - - /* - * The CRD's own rules, refused here rather than accepted and forgotten. - * - * A fixture that took anything would let a form ship that builds a resource a - * cluster rejects — which is the failure this backend exists to catch, and the one - * that is invisible until somebody tries it for real. - */ - const adapters = ["kagent", "codex", "claude"].filter( + const adapters = ["kagent", "codex", "claude", "byo"].filter( (key) => (spec as Record)[key] !== undefined, ); if (adapters.length !== 1) { throw new ConnectError( - "exactly one of kagent, codex, or claude must be specified", + "exactly one of kagent, codex, claude, or byo must be specified", Code.InvalidArgument, ); } - if (!/^[^\s@]+@sha256:[a-f0-9]{64}$/.test(spec.workload?.image ?? "")) { + const image = spec.workload?.image; + if (image !== undefined && !/^[^\s@]+@sha256:[a-f0-9]{64}$/.test(image)) { throw new ConnectError( "spec.workload.image must be pinned by sha256 digest", Code.InvalidArgument, ); } + if (spec.byo !== undefined && image === undefined) { + throw new ConnectError("BYO harnesses must specify workload.image", Code.InvalidArgument); + } + if (spec.byo !== undefined && (spec.workload?.command ?? []).length === 0) { + throw new ConnectError("BYO harnesses must specify workload.command", Code.InvalidArgument); + } if (!spec.substrate?.workerPoolRef?.name) { throw new ConnectError("workerPoolRef name must not be empty", Code.InvalidArgument); } + if (!/^[^\s]+$/.test(spec.substrate.snapshotPolicy?.location ?? "")) { + throw new ConnectError("spec.substrate.snapshotPolicy.location is required", Code.InvalidArgument); + } +} - const saved = saveHarness(harnessFromResource(namespace, name, value)); +function harnessMessage(harness: Harness) { return { - harness: { - ref: { namespace: saved.namespace, name: saved.name }, - resource: structured("Harness", saved.resource as unknown as JsonObject), - runtime: saved.runtime, - workloadImage: saved.workloadImage, - }, + ref: { namespace: harness.namespace, name: harness.name }, + resource: structured("Harness", harness.resource as unknown as JsonObject), + runtime: harness.runtime, + workloadImage: harness.workloadImage, + }; +} + +on(HarnessService.method.createHarness, (input, call) => { + const namespace = requireNamespace(input.ref?.namespace ?? ""); + const name = input.ref?.name ?? ""; + if (name === "") { + throw new ConnectError("Harness namespace and name are required", Code.InvalidArgument); + } + if (call.scenario === "error") { + throw new ConnectError("the mock backend was asked to fail", Code.Internal); + } + if (allHarnesses().some((row) => row.namespace === namespace && row.name === name)) { + throw new ConnectError(`Harness ${namespace}/${name} already exists`, Code.AlreadyExists); + } + const value = (input.resource?.value ?? {}) as JsonObject; + admitHarnessSpec(value); + const created = harnessFromResource(namespace, name, value); + const metadata = { ...created.resource.metadata, resourceVersion: "1" }; + return { harness: harnessMessage(saveHarness({ ...created, resource: { ...created.resource, metadata } })) }; +}); + +// Replaces spec and labels only, and refuses a stale resourceVersion as the controller does. +on(HarnessService.method.updateHarness, (input, call) => { + const namespace = requireNamespace(input.ref?.namespace ?? ""); + const name = input.ref?.name ?? ""; + if (call.scenario === "error") { + throw new ConnectError("the mock backend was asked to fail", Code.Internal); + } + const previous = allHarnesses().find((row) => row.namespace === namespace && row.name === name); + if (!previous) throw new ConnectError(`Harness ${namespace}/${name} not found`, Code.NotFound); + const value = (input.resource?.value ?? {}) as JsonObject; + const metadata = (value.metadata ?? {}) as { name?: string; namespace?: string; resourceVersion?: string; labels?: Record }; + if ((metadata.name && metadata.name !== name) || (metadata.namespace && metadata.namespace !== namespace)) { + throw new ConnectError("resource metadata must match the request ref", Code.InvalidArgument); + } + const stored = previous.resource.metadata.resourceVersion; + if (metadata.resourceVersion && stored && metadata.resourceVersion !== stored) { + throw new ConnectError("Harness was modified; reload and retry", Code.Aborted); + } + admitHarnessSpec(value); + const next = harnessFromResource(namespace, name, { ...value, metadata: {} }); + return { + harness: harnessMessage( + saveHarness({ + ...next, + resource: { + ...next.resource, + metadata: { + ...previous.resource.metadata, + labels: metadata.labels, + resourceVersion: String((Number(stored) || 1) + 1), + }, + }, + }), + ), }; }); @@ -1365,6 +1415,17 @@ function substratePage(rows: T[], pageSize: number, pageToken: string) { }; } +// Kubernetes only, like the controller, so it answers whatever ate-api is doing. +on(SystemService.method.listWorkerPools, (input, call) => { + if (call.scenario === "empty") return { workerPools: [] }; + const inScope = substrateScope(input.namespace); + return { + workerPools: mockSubstrateInventory.workerPools + .filter((pool) => inScope(pool.namespace)) + .map(substrateWorkerPoolMessage), + }; +}); + on(SystemService.method.getSubstrateSummary, (input, call) => { if (call.scenario === "empty") return {}; diff --git a/ui/src/pages/AgentPage.tsx b/ui/src/pages/AgentPage.tsx index 8955d85c69..7c0d5ea3d0 100644 --- a/ui/src/pages/AgentPage.tsx +++ b/ui/src/pages/AgentPage.tsx @@ -18,6 +18,7 @@ import { } from "@/appExtensions"; import { apiClient, + agentUpdateFailedReason, isNotFound, newConversationBlockedReason, useAgentConversations, @@ -123,6 +124,7 @@ export function AgentPage() { }; const blockedReason = agent ? newConversationBlockedReason(agent) : undefined; + const updateFailedReason = agent ? agentUpdateFailedReason(agent) : undefined; /* * Goes to the new-conversation page rather than creating one here. @@ -413,6 +415,20 @@ export function AgentPage() { /> ) : null} + {updateFailedReason ? ( + + ) : null} + {conversations.error ? ( ( () => (spec?.egressPolicyRefs ?? []).map((ref) => ref.name), ); @@ -106,7 +111,17 @@ function AgentForm({ agent }: { agent?: Agent }) { const [failure, setFailure] = useState(); const inlineTemplate = { ...templateDraft, namespace }; + const harnessNames = (harnesses.data ?? []).map((row) => row.name); + // A new agent starts on the obvious harness until one is chosen; an edit keeps its own. + const harnessRef = chosenHarness ?? (agent ? undefined : preferredHarness(harnessNames)); const adapter = harnesses.data?.find((row) => row.name === harnessRef)?.runtime; + const modelRef = templateSource === "reference" + ? splitRef(templates.data?.find((row) => row.name === templateRef)?.modelConfigRef) + : inlineTemplate.modelConfig?.name.trim() + ? { namespace: inlineTemplate.modelConfig.namespace || namespace, name: inlineTemplate.modelConfig.name.trim() } + : undefined; + const model = useModel(modelRef?.namespace, modelRef?.name); + const conflict = harnessModelConflict(adapter, model.data?.spec); const problems = [ ...(namespace ? [] : ["A namespace is required."]), ...(name.trim() ? [] : ["A name is required."]), @@ -184,6 +199,79 @@ function AgentForm({ agent }: { agent?: Agent }) { + +
Harness
+ + Where the agent runs: the runtime adapter, its image and the Substrate worker pool. + + + } + extra={ + + } + > + + No harnesses in this namespace.{" "} + + + } + /> + {conflict ? ( + + ) : null} +
+ + {/* + A controlled modal rather than `Modal.confirm`: antd 6's static methods cannot + read context, and the warning they log is a failure in the browser suite. + */} + setCreatingHarness(false)} + data-testid="agent-form-harness-modal" + > + + { + setHarnessRef(saved.name); + setCreatingHarness(false); + toast.success(`Harness ${saved.name} created`); + }} + onCancel={() => setCreatingHarness(false)} + /> + + - -
Harness
- - Platform-managed runtime, image and worker pool. - - - } - > - row.name)} - empty={ - - No harnesses in this namespace. Create a harness - - } - /> -
- {problems.length > 0 ? ( - - {problems[0]} - +
    + {problems.map((problem) => ( +
  • {problem}
  • + ))} +
) : null} ); } +/** `namespace/name` as its parts; undefined for an empty ref. */ +function splitRef(ref: string | undefined): { namespace: string; name: string } | undefined { + const [namespace, name] = ref?.split("/") ?? []; + return namespace && name ? { namespace, name } : undefined; +} + function SourceSection({ kind, title, diff --git a/ui/src/pages/agents/HarnessNewPage.tsx b/ui/src/pages/agents/HarnessNewPage.tsx index 5cbf09325b..3d30c02c97 100644 --- a/ui/src/pages/agents/HarnessNewPage.tsx +++ b/ui/src/pages/agents/HarnessNewPage.tsx @@ -1,115 +1,84 @@ -import { useState } from "react"; -import { Alert, Button, Card, Form, Input, Select, Space } from "antd"; -import { useTheme } from "@emotion/react"; -import { useNavigate } from "react-router-dom"; +import { Alert, Card, Skeleton } from "antd"; +import toast from "react-hot-toast"; +import { useNavigate, useParams } from "react-router-dom"; import { PageFrame } from "@/components/Structure/PageFrame"; -import { apiClient, useInvalidateHarnesses, useNamespaces } from "@/api"; -import { HarnessFields } from "@/components/harness-form/HarnessFields"; -import { - emptyHarnessDraft, - harnessDraftProblems, - harnessSpecFromDraft, -} from "@/components/harness-form/harnessDraft"; +import { useHarnesses } from "@/api"; +import { HarnessForm } from "@/components/harness-form/HarnessForm"; +import { HarnessIntro } from "@/components/harness-form/HarnessFields"; import { paths } from "@/router/routes"; +const harnessesTab = `${paths.agents}?tab=harnesses`; + export function HarnessNewPage() { - const theme = useTheme(); const navigate = useNavigate(); - const namespaces = useNamespaces(); - const invalidateHarnesses = useInvalidateHarnesses(); - - const [namespace, setNamespace] = useState(); - const [name, setName] = useState(""); - const [draft, setDraft] = useState(emptyHarnessDraft); - const [saving, setSaving] = useState(false); - const [failure, setFailure] = useState(); - - const ready = - Boolean(namespace) && name.trim() !== "" && harnessDraftProblems(draft).length === 0; - - async function create() { - if (!namespace || !ready) return; - setSaving(true); - setFailure(undefined); - try { - await apiClient.agentBuildingBlocks.createHarness({ - namespace, - name: name.trim(), - resource: { - metadata: { name: name.trim(), namespace }, - spec: harnessSpecFromDraft(draft), - }, - }); - // Refreshes any list still on screen; SWR does not fetch a key with no mounted - // subscriber, so the one navigated to re-reads on mount. Guarded, not load-bearing. - await invalidateHarnesses().catch(() => {}); - navigate(`${paths.agents}?tab=harnesses`); - } catch (cause: unknown) { - // The controller's own words: its CEL messages name the field that was wrong, - // which is more use than anything this form could say about it. - setFailure(cause instanceof Error ? cause.message : String(cause)); - } finally { - setSaving(false); - } - } - return ( -
- - setName(event.target.value)} - placeholder="my-harness" - /> - - - -